mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
873
Commits
v0.1.1
..
d36b28c55a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d36b28c55a | ||
|
|
6b41340f1d | ||
|
|
4b9ea43402 | ||
|
|
e5f23a9189 | ||
|
|
15e3e32b47 | ||
|
|
a5d73fe4d7 | ||
|
|
e01c8d3cc7 | ||
|
|
925695a44a | ||
|
|
e5b205391e | ||
|
|
e9762dda21 | ||
|
|
5bfdbd5c8f | ||
|
|
1d5c71730f | ||
|
|
ad43569fe5 | ||
|
|
9ea30e6ee9 | ||
|
|
a040ce5f8e | ||
|
|
328d17599b | ||
|
|
83256b5bf7 | ||
|
|
35191c00e0 | ||
|
|
b08cee03e3 | ||
|
|
99e1ae0edb | ||
|
|
5f07bc5832 | ||
|
|
8aa0a96c23 | ||
|
|
8b95db66d1 | ||
|
|
69acf6f58e | ||
|
|
18b4112a33 | ||
|
|
23d6b4409a | ||
|
|
0c42a7df65 | ||
|
|
e3823c29bf | ||
|
|
2f717a10a7 | ||
|
|
7bb3cb9682 | ||
|
|
247154eeb3 | ||
|
|
7bac40ae3e | ||
|
|
d36ff67409 | ||
|
|
3841da9e1d | ||
|
|
cffa03007b | ||
|
|
73d515bb40 | ||
|
|
0c3b8b122f | ||
|
|
2f76187bf5 | ||
|
|
f275a31096 | ||
|
|
e622c06ca6 | ||
|
|
fffc6a8b8a | ||
|
|
cccb8635e1 | ||
|
|
1247f961c2 | ||
|
|
3586a32987 | ||
|
|
24b81af43c | ||
|
|
11df8e4a75 | ||
|
|
9424daff28 | ||
|
|
26951773d1 | ||
|
|
7616f1365a | ||
|
|
2aaaa70b15 | ||
|
|
442b5c62bf | ||
|
|
71da32549a | ||
|
|
9236aa341a | ||
|
|
0dd4bc9d36 | ||
|
|
07acb31bf3 | ||
|
|
a26af3b2ae | ||
|
|
29faeef5a5 | ||
|
|
2f159157cc | ||
|
|
28c88e4e10 | ||
|
|
7f7d091429 | ||
|
|
424263bdd4 | ||
|
|
c95136eae6 | ||
|
|
03ff4cd51d | ||
|
|
519825c34a | ||
|
|
eebb7b51ed | ||
|
|
5ecd09aa2b | ||
|
|
34560470ce | ||
|
|
dd68079005 | ||
|
|
d23f1bdc00 | ||
|
|
4503b17046 | ||
|
|
4a12217e65 | ||
|
|
e7a62f6908 | ||
|
|
3310b478a1 | ||
|
|
8384d4162c | ||
|
|
90af550693 | ||
|
|
fe829efbf5 | ||
|
|
cd07278858 | ||
|
|
45cf87a0cb | ||
|
|
9ce624facd | ||
|
|
c38d916dbb | ||
|
|
50057c42e0 | ||
|
|
f379bf4c14 | ||
|
|
dd3d809c6e | ||
|
|
44897f351f | ||
|
|
c888e0a76f | ||
|
|
7b554cd703 | ||
|
|
d340659370 | ||
|
|
6fb68c22c5 | ||
|
|
b1897ce719 | ||
|
|
47199c36ce | ||
|
|
d812cf8f6e | ||
|
|
39690eed72 | ||
|
|
56a54ab3fd | ||
|
|
303c8e4506 | ||
|
|
8afe52367f | ||
|
|
5b5cd44d68 | ||
|
|
19c13f6cd2 | ||
|
|
0f5f8727a3 | ||
|
|
2045819735 | ||
|
|
cb72f67560 | ||
|
|
b852d34068 | ||
|
|
7e1b91c372 | ||
|
|
6409a47c71 | ||
|
|
f44ada06b9 | ||
|
|
3a79aeffcc | ||
|
|
84990ad692 | ||
|
|
8d023f5c53 | ||
|
|
1c0d48cb46 | ||
|
|
4d5ca42a9d | ||
|
|
fdbffbf020 | ||
|
|
102421df19 | ||
|
|
f8234916cb | ||
|
|
208a561003 | ||
|
|
6e9acefbe6 | ||
|
|
39181708dd | ||
|
|
e8111a419f | ||
|
|
9cfcf49d28 | ||
|
|
60ff088971 | ||
|
|
3eda6cdc22 | ||
|
|
b1be80adf2 | ||
|
|
02a318cd2e | ||
|
|
f41590157f | ||
|
|
dd989a281e | ||
|
|
f60a9ff293 | ||
|
|
07e380b95f | ||
|
|
4e060914ec | ||
|
|
f317dde168 | ||
|
|
f2f6fee89a | ||
|
|
b3b33a82ba | ||
|
|
cd149e4506 | ||
|
|
eff1bc88d8 | ||
|
|
ab644f6a47 | ||
|
|
883f184e37 | ||
|
|
897f30fd36 | ||
|
|
943fea0515 | ||
|
|
e96cce88c1 | ||
|
|
1930bb5766 | ||
|
|
a844cc6f67 | ||
|
|
043b85ab70 | ||
|
|
fe813302b7 | ||
|
|
3e30a59d4c | ||
|
|
0da9df17a4 | ||
|
|
0e820f9c52 | ||
|
|
772b31b57f | ||
|
|
1c8b77054a | ||
|
|
cef047221c | ||
|
|
4b34c42b76 | ||
|
|
7ec8879af3 | ||
|
|
cb77d42107 | ||
|
|
d45b5a488e | ||
|
|
f4581eac11 | ||
|
|
0651b9d0ac | ||
|
|
15037f382a | ||
|
|
62976f3338 | ||
|
|
d38312fec6 | ||
|
|
636307d3ea | ||
|
|
e22cdf0b18 | ||
|
|
9aee75ba28 | ||
|
|
37c937a570 | ||
|
|
a1bde30b57 | ||
|
|
06c2bc55f2 | ||
|
|
0a9a1db815 | ||
|
|
c3e666056f | ||
|
|
12c82bdb05 | ||
|
|
7eaf42444d | ||
|
|
117c2abd23 | ||
|
|
9fb20c30b3 | ||
|
|
ba73259887 | ||
|
|
a0dbe4a983 | ||
|
|
21c65b9dbe | ||
|
|
86251a9354 | ||
|
|
30bcb021d2 | ||
|
|
ada9bec2d4 | ||
|
|
18ea1bc4a6 | ||
|
|
be0f833e21 | ||
|
|
98439b7743 | ||
|
|
2ab58d24d4 | ||
|
|
a5baf6e5c3 | ||
|
|
6c75277e59 | ||
|
|
76880d901a | ||
|
|
3f95583dd4 | ||
|
|
f5a77b82b2 | ||
|
|
b4b1232375 | ||
|
|
e4cb71b878 | ||
|
|
95f80a7fda | ||
|
|
442f1d97a1 | ||
|
|
d2703cbc6b | ||
|
|
0f4c211002 | ||
|
|
68661e57c3 | ||
|
|
1735ef9521 | ||
|
|
838839a7e5 | ||
|
|
940732e91c | ||
|
|
9e4c759ffc | ||
|
|
1525d182b5 | ||
|
|
8ae38cfd57 | ||
|
|
6cc3a05ed2 | ||
|
|
29a682f1ce | ||
|
|
b2fce73254 | ||
|
|
7f92a5db27 | ||
|
|
a5c9085105 | ||
|
|
d344392c1b | ||
|
|
2992bad05d | ||
|
|
255448a59c | ||
|
|
cf3234aef9 | ||
|
|
9e39f03f75 | ||
|
|
33fcb2fe74 | ||
|
|
d5687cd713 | ||
|
|
a67f936cc1 | ||
|
|
51b8c29d63 | ||
|
|
e6f013dbc8 | ||
|
|
897f03aafd | ||
|
|
7b306f25ce | ||
|
|
78a3551c77 | ||
|
|
38c7dda6a0 | ||
|
|
51c7b5ba8f | ||
|
|
a507b202ec | ||
|
|
d9934b9dba | ||
|
|
1f328e99d5 | ||
|
|
3c435434a8 | ||
|
|
e54871758b | ||
|
|
c039390ade | ||
|
|
bad0f97b66 | ||
|
|
2405b156e4 | ||
|
|
4de17cb63c | ||
|
|
2222434934 | ||
|
|
3f1a772288 | ||
|
|
7b9bed0780 | ||
|
|
aef4b2213f | ||
|
|
44013da332 | ||
|
|
b48ebe2acc | ||
|
|
eaa4455320 | ||
|
|
ce3cb8a34f | ||
|
|
e6705599b3 | ||
|
|
7b88040c73 | ||
|
|
7a9eac593f | ||
|
|
17c1bb5c21 | ||
|
|
a5a9ef5444 | ||
|
|
2940965e31 | ||
|
|
185cf19a0c | ||
|
|
4443fa9e0e | ||
|
|
3056e2af11 | ||
|
|
7bfaf31138 | ||
|
|
f8d1945544 | ||
|
|
93d7f29d31 | ||
|
|
18ebd29032 | ||
|
|
fe56d6d708 | ||
|
|
14056cc584 | ||
|
|
d3ee7399eb | ||
|
|
5c54e9d059 | ||
|
|
ab6f856987 | ||
|
|
6e59657fe2 | ||
|
|
215f21439d | ||
|
|
53bf446502 | ||
|
|
2588279e52 | ||
|
|
f48dc250a9 | ||
|
|
15d9ceb4d1 | ||
|
|
a4a8e70f9c | ||
|
|
1d4dbd96ce | ||
|
|
476a2d57ca | ||
|
|
ce46a67d6d | ||
|
|
87e49dfb62 | ||
|
|
808bda67d4 | ||
|
|
c91a964513 | ||
|
|
e0a63a3f49 | ||
|
|
0b2e1bb7a2 | ||
|
|
7c4f739d07 | ||
|
|
3bd9400051 | ||
|
|
1caa5ccf0e | ||
|
|
76affeb6b2 | ||
|
|
a945291371 | ||
|
|
06c3fba96e | ||
|
|
7e739dd2f0 | ||
|
|
6c2f85d38a | ||
|
|
1ea1d15845 | ||
|
|
5e0d4d0f05 | ||
|
|
9a1d2e0d46 | ||
|
|
878f96a4b7 | ||
|
|
9efaab2beb | ||
|
|
2c34ba7eff | ||
|
|
57e2e22266 | ||
|
|
4ec5c15254 | ||
|
|
33a3683196 | ||
|
|
4be1ea3506 | ||
|
|
f86e63c851 | ||
|
|
f6cf73190f | ||
|
|
5308c87651 | ||
|
|
9bd5ffcd2d | ||
|
|
8d8acd5410 | ||
|
|
4d97c30a9c | ||
|
|
aa71ab0220 | ||
|
|
2345ee9856 | ||
|
|
65e9cdd444 | ||
|
|
85a2615db1 | ||
|
|
777615bc56 | ||
|
|
bac34ebd0c | ||
|
|
b2322e1c49 | ||
|
|
436671457d | ||
|
|
fc847093d2 | ||
|
|
d2a7af744d | ||
|
|
5acf557675 | ||
|
|
0b32cd2095 | ||
|
|
425b974331 | ||
|
|
db09dec855 | ||
|
|
20cdcdf917 | ||
|
|
796363f23c | ||
|
|
cb0df2169f | ||
|
|
95afc7f289 | ||
|
|
bc65c73c51 | ||
|
|
596bc561b8 | ||
|
|
f215fae6c7 | ||
|
|
5707c37b63 | ||
|
|
5780d3a8a4 | ||
|
|
ac18ff3e8b | ||
|
|
a9a720f3c3 | ||
|
|
4b4b721d49 | ||
|
|
6373d4097c | ||
|
|
dfe12ca644 | ||
|
|
2ef8a6fd20 | ||
|
|
27cfe1c4f3 | ||
|
|
ebd4622592 | ||
|
|
4fa30ab8cb | ||
|
|
1e6aaf451e | ||
|
|
bf65fbaca5 | ||
|
|
79dc05cb66 | ||
|
|
234985b57d | ||
|
|
d89bfb8e03 | ||
|
|
91a09d5d37 | ||
|
|
e87207eae3 | ||
|
|
3c33973269 | ||
|
|
3019377310 | ||
|
|
53def3a624 | ||
|
|
eb23e956cb | ||
|
|
d029d4d0bf | ||
|
|
4433095045 | ||
|
|
05c8117f7b | ||
|
|
3fbba6d540 | ||
|
|
439d956ac2 | ||
|
|
055cfae272 | ||
|
|
76b4369aa6 | ||
|
|
74c7738bc3 | ||
|
|
f6d75d507c | ||
|
|
6a1cd7bf87 | ||
|
|
57205e1149 | ||
|
|
ce4a8cce72 | ||
|
|
75085b3929 | ||
|
|
c489fcc63c | ||
|
|
e30ea89990 | ||
|
|
9ddcbd6de0 | ||
|
|
939645e144 | ||
|
|
1d413734d8 | ||
|
|
2e655d870c | ||
|
|
eea81723b8 | ||
|
|
bbdeedb44e | ||
|
|
3e64af910a | ||
|
|
fcdbf785bb | ||
|
|
7ebcba7c36 | ||
|
|
90d244fea8 | ||
|
|
497b9483f6 | ||
|
|
e33fd40033 | ||
|
|
2b93a1c089 | ||
|
|
15d4ef5518 | ||
|
|
2f5f515696 | ||
|
|
30bb33f1a0 | ||
|
|
7bff706aa0 | ||
|
|
b0cd752619 | ||
|
|
52dd0c71a5 | ||
|
|
2d292ed053 | ||
|
|
9770151583 | ||
|
|
89db50e8df | ||
|
|
3ac5992e1d | ||
|
|
8c4f21ff81 | ||
|
|
4de068c46e | ||
|
|
b6dfc2175f | ||
|
|
48d2239f5d | ||
|
|
06d988583d | ||
|
|
abea3e229e | ||
|
|
1c3f210848 | ||
|
|
722615cd9b | ||
|
|
809d1eca77 | ||
|
|
6e4439bf49 | ||
|
|
882f2f5d03 | ||
|
|
dde23b5549 | ||
|
|
c3ecd378f8 | ||
|
|
6da8e98c62 | ||
|
|
4d591f9efc | ||
|
|
26c3699a9e | ||
|
|
328ace1fd1 | ||
|
|
91bf135a4f | ||
|
|
e2b3260926 | ||
|
|
179289b504 | ||
|
|
bd8a843972 | ||
|
|
7e366ff980 | ||
|
|
d0c23fa840 | ||
|
|
4bc47af05b | ||
|
|
65c3e0a68d | ||
|
|
5c4f49b685 | ||
|
|
ba6290d616 | ||
|
|
6af7a9861d | ||
|
|
3b4bcb7932 | ||
|
|
0153e9f9e2 | ||
|
|
9a0b1a4ce3 | ||
|
|
e251dd950b | ||
|
|
40862df038 | ||
|
|
dc569175ef | ||
|
|
6ba98e4576 | ||
|
|
75b932b66e | ||
|
|
5aa85ab501 | ||
|
|
521ccf2026 | ||
|
|
e8b4dd0bd1 | ||
|
|
265042e2c8 | ||
|
|
1c85112d4f | ||
|
|
0fc5032b27 | ||
|
|
893ab1f9f5 | ||
|
|
a30ed600b6 | ||
|
|
a2c6cbe633 | ||
|
|
1f9c2c873d | ||
|
|
509c9eb7d3 | ||
|
|
fba03b3df7 | ||
|
|
c6d18f5d49 | ||
|
|
3e804d9ecc | ||
|
|
784d86d8e8 | ||
|
|
3bac1e61b9 | ||
|
|
6d2e100d63 | ||
|
|
cce81ecaad | ||
|
|
f8bf6bccd3 | ||
|
|
82fd9744bf | ||
|
|
cdff036935 | ||
|
|
458d282357 | ||
|
|
59074a275f | ||
|
|
92975eec51 | ||
|
|
e0d11a8abc | ||
|
|
fa004d63bb | ||
|
|
a217643ac9 | ||
|
|
fc3297c7b7 | ||
|
|
684f0d38ee | ||
|
|
332e7bca90 | ||
|
|
ae458d260f | ||
|
|
6b758c810e | ||
|
|
ffed906cef | ||
|
|
bcc504b8cc | ||
|
|
782f4cb20b | ||
|
|
aebd2ac232 | ||
|
|
2969b40ad4 | ||
|
|
e0045c7570 | ||
|
|
bc6f2fd49d | ||
|
|
cd25b973ea | ||
|
|
27bbbe7186 | ||
|
|
20cf98d42e | ||
|
|
63c4de512e | ||
|
|
337c15de35 | ||
|
|
2d361d3ee6 | ||
|
|
977fdf3cc3 | ||
|
|
9f6880b701 | ||
|
|
1a1afece7d | ||
|
|
08f233c5b1 | ||
|
|
9438654d99 | ||
|
|
60926f64cc | ||
|
|
b6b8286118 | ||
|
|
8f906cfd95 | ||
|
|
da03ed8ece | ||
|
|
7239cd3a6b | ||
|
|
3db07ca2d4 | ||
|
|
f8fe3c7af3 | ||
|
|
b6e9766f40 | ||
|
|
1f62e30a08 | ||
|
|
88f05ffd75 | ||
|
|
375bb385a2 | ||
|
|
51840e0366 | ||
|
|
831c581e0f | ||
|
|
e1734a1aa6 | ||
|
|
84376bb34c | ||
|
|
6d0b1dc1ee | ||
|
|
bc078472d4 | ||
|
|
8a273dbf63 | ||
|
|
001acfc6a5 | ||
|
|
7b88b8b810 | ||
|
|
97bdb8eae1 | ||
|
|
5baa8087e5 | ||
|
|
d25864e52f | ||
|
|
ec2787c99c | ||
|
|
9963f9df2d | ||
|
|
835834322b | ||
|
|
51ba3c7df8 | ||
|
|
047f661462 | ||
|
|
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 |
@@ -4,6 +4,7 @@
|
||||
# Allow files and directories.
|
||||
!cmd/
|
||||
!internal/
|
||||
!pkg/
|
||||
!scripts/docker/
|
||||
!go.*
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**How to reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior:
|
||||
|
||||
1. Run ...
|
||||
2. Do ...
|
||||
-->
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
**Environment:**
|
||||
|
||||
- Uncloud versions:
|
||||
- Control (client) node (`uc --version`):
|
||||
- Uncloud daemon (from the server) (`uncloudd --version`):
|
||||
- OS version (`uname -a`):
|
||||
- Client (control node):
|
||||
- Server:
|
||||
|
||||
**Additional context**
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../AI.md
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 635 KiB |
@@ -0,0 +1,82 @@
|
||||
name: Go Tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "test/**"
|
||||
- "release/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/**"
|
||||
- "**.go"
|
||||
- "**.proto"
|
||||
- "go.*"
|
||||
- "Makefile"
|
||||
- "scripts/**"
|
||||
- "test/**"
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
MISE_VERSION: "2026.3.7"
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Mise
|
||||
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
|
||||
with:
|
||||
version: ${{ env.MISE_VERSION }}
|
||||
- name: Reset changes in mise.lock after fresh mise install
|
||||
run: git checkout mise.lock
|
||||
|
||||
- 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: Generate docs
|
||||
run: |
|
||||
make cli-docs
|
||||
git diff --exit-code ||
|
||||
(echo "Documentation is out of date. Please run 'make cli-docs' and commit the changes." && exit 1)
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
mise ucind:image
|
||||
make test
|
||||
timeout-minutes: 15
|
||||
|
||||
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@1648a7812b9aeae629881980618f079932869151 # v4.0.1
|
||||
with:
|
||||
version: ${{ env.MISE_VERSION }}
|
||||
|
||||
# TODO: figure out how to properly lock all platforms in the lockfile and remove this hack.
|
||||
# `mise lock` (mise run mise:lock) doesn't generate checksums for some of the tools, e.g. protoc*
|
||||
- name: Reset changes in mise.lock after fresh mise install
|
||||
run: git checkout mise.lock
|
||||
|
||||
- name: Generate Protobuf files
|
||||
run: |
|
||||
mise run 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 'mise run proto' and commit the changes." && exit 1)
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Lint
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "test/**"
|
||||
- "release/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.*"
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
MISE_VERSION: "2026.3.7"
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Mise
|
||||
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
|
||||
with:
|
||||
version: ${{ env.MISE_VERSION }}
|
||||
- name: Reset changes in mise.lock after fresh mise install
|
||||
run: git checkout mise.lock
|
||||
|
||||
- name: Lint
|
||||
run: make lint
|
||||
|
||||
timeout-minutes: 10
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Nightly Build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
MISE_VERSION: "2026.3.7"
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
# Run on macOS to be able to build the fsevent dependency required by compose on macOS.
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Mise
|
||||
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
|
||||
with:
|
||||
version: ${{ env.MISE_VERSION }}
|
||||
- name: Reset changes in mise.lock after fresh mise install
|
||||
run: git checkout mise.lock
|
||||
|
||||
- name: Cache Go build and module cache
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-${{ hashFiles('**/*.go') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Run GoReleaser snapshot
|
||||
run: goreleaser release --snapshot --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Delete existing nightly release
|
||||
run: gh release delete nightly --yes --cleanup-tag || true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create nightly release
|
||||
run: |
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
DATE=$(date -u '+%Y-%m-%d %H:%M UTC')
|
||||
RELEASE_NOTES=$(cat <<EOF
|
||||
Built from \`main\` at ${COMMIT} on ${DATE}.
|
||||
|
||||
The commands below show how to install the nightly version of uncloud.
|
||||
|
||||
> **Warning**: These are nightly development builds and may be unstable.
|
||||
|
||||
**Install uncloud CLI:**
|
||||
|
||||
\`\`\`
|
||||
curl -fsS https://get.uncloud.run/install.sh | VERSION=nightly sh
|
||||
\`\`\`
|
||||
|
||||
**Initialize a cluster or add a machine:**
|
||||
|
||||
\`\`\`
|
||||
uc machine init --version nightly user@host
|
||||
uc machine add --version nightly user@host
|
||||
\`\`\`
|
||||
|
||||
**Upgrade machine daemon:**
|
||||
|
||||
\`\`\`
|
||||
ARCH=\$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
||||
curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/nightly/uncloudd_linux_\${ARCH}.tar.gz
|
||||
tar -xf uncloudd.tar.gz
|
||||
sudo install uncloudd /usr/local/bin/uncloudd
|
||||
rm uncloudd uncloudd.tar.gz
|
||||
sudo systemctl restart uncloud
|
||||
\`\`\`
|
||||
|
||||
EOF
|
||||
)
|
||||
|
||||
gh release create nightly \
|
||||
--title "Nightly Build" \
|
||||
--notes "${RELEASE_NOTES}" \
|
||||
--prerelease \
|
||||
--target ${{ github.sha }} \
|
||||
dist/*.tar.gz dist/checksums.txt
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
timeout-minutes: 30
|
||||
+14
-2
@@ -7,6 +7,10 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/uncloud
|
||||
/uncloud-*
|
||||
/uncloudd
|
||||
/uncloudd-*
|
||||
|
||||
# OS X
|
||||
.DS_Store
|
||||
@@ -27,8 +31,16 @@ go.work.sum
|
||||
.env
|
||||
.idea/
|
||||
|
||||
data
|
||||
dist/
|
||||
/data/
|
||||
/dist/
|
||||
/uc
|
||||
/tmp
|
||||
|
||||
# Web
|
||||
node_modules/
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
.devcontainer/
|
||||
|
||||
.zed/
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-9
@@ -11,12 +11,21 @@ before:
|
||||
|
||||
report_sizes: true
|
||||
|
||||
git:
|
||||
# Skip the stale 'nightly' tag so {{.Version}} resolves to the latest semver tag for --snapshot builds.
|
||||
ignore_tags:
|
||||
- nightly
|
||||
snapshot:
|
||||
# Semver-compliant nightly version, e.g. "0.19.0-nightly-abc1234" when the latest tag is v0.18.0.
|
||||
version_template: "{{ incminor .Version }}-nightly-{{.ShortCommit}}"
|
||||
|
||||
builds:
|
||||
- id: uncloud
|
||||
main: ./cmd/uncloud
|
||||
binary: uncloud
|
||||
- id: uc
|
||||
main: ./cmd/uc
|
||||
binary: uc
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
# Use CGO to be able to build fsevent required by compose on macOS.
|
||||
- CGO_ENABLED={{ if eq .Os "darwin" }}1{{ else }}0{{ end }}
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
@@ -24,6 +33,13 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
-X github.com/psviderski/uncloud/internal/version.version={{.Version}}
|
||||
-X github.com/psviderski/uncloud/internal/version.commit={{.Commit}}
|
||||
-X github.com/psviderski/uncloud/internal/version.dirty={{.IsGitDirty}}
|
||||
-X github.com/psviderski/uncloud/internal/version.date={{.CommitDate}}
|
||||
-X github.com/psviderski/uncloud/internal/version.builtBy=goreleaser
|
||||
|
||||
- id: uncloudd
|
||||
main: ./cmd/uncloudd
|
||||
@@ -35,11 +51,18 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
-X github.com/psviderski/uncloud/internal/version.version={{.Version}}
|
||||
-X github.com/psviderski/uncloud/internal/version.commit={{.Commit}}
|
||||
-X github.com/psviderski/uncloud/internal/version.dirty={{.IsGitDirty}}
|
||||
-X github.com/psviderski/uncloud/internal/version.date={{.CommitDate}}
|
||||
-X github.com/psviderski/uncloud/internal/version.builtBy=goreleaser
|
||||
|
||||
archives:
|
||||
- id: uncloud
|
||||
- id: uc
|
||||
builds:
|
||||
- uncloud
|
||||
- uc
|
||||
formats:
|
||||
# gz is not compatible with homebrew releases, so we use tar.gz
|
||||
- tar.gz
|
||||
@@ -80,10 +103,9 @@ brews:
|
||||
description: "Uncloud CLI"
|
||||
homepage: "https://uncloud.run"
|
||||
ids:
|
||||
- uncloud
|
||||
- uc
|
||||
install: |
|
||||
bin.install "uncloud"
|
||||
bin.install_symlink "uncloud" => "uc"
|
||||
bin.install "uc"
|
||||
skip_upload: false
|
||||
repository:
|
||||
owner: psviderski
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
[tools]
|
||||
go = "1.23"
|
||||
|
||||
[env]
|
||||
_.file = ".env"
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
#MISE description="Build and install uncloud binaries on dev machines, restart daemon if running"
|
||||
#MISE depends=["build:uncloudd-amd64", "build:uncloudd-arm64"]
|
||||
#MISE raw=true
|
||||
#USAGE arg "<hosts>" var=#true var_min=1 help="Hosts in [user@]host[/arch] format, arch defaults to amd64"
|
||||
|
||||
for arg in ${usage_hosts}; do
|
||||
if [[ "$arg" == */* ]]; then
|
||||
host="${arg%/*}"
|
||||
arch="${arg##*/}"
|
||||
else
|
||||
host="$arg"
|
||||
arch="amd64"
|
||||
fi
|
||||
|
||||
binary="uncloudd-linux-${arch}"
|
||||
echo "==> Installing uncloudd on ${host} (${arch})..."
|
||||
scp "$binary" "${host}:/tmp/"
|
||||
ssh "$host" "sudo install /tmp/${binary} /usr/local/bin/uncloudd && rm /tmp/${binary} && sudo systemctl try-restart uncloud"
|
||||
done
|
||||
|
||||
rm -f uncloudd-linux-amd64 uncloudd-linux-arm64
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
#MISE description="Stop uncloud daemon and wipe state on dev machines"
|
||||
#USAGE arg "<hosts>" var=#true var_min=1 help="Hosts in [user@]host format"
|
||||
|
||||
for host in ${usage_hosts}; do
|
||||
# Strip optional /arch suffix since reset doesn't need it.
|
||||
host="${host%/*}"
|
||||
echo "==> Resetting ${host}..."
|
||||
ssh "$host" "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||
done
|
||||
|
||||
echo "==> Done"
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
github.com/psviderski/uncloud/internal/machine/caddyconfig:
|
||||
interfaces:
|
||||
CaddyfileValidator:
|
||||
@@ -0,0 +1,266 @@
|
||||
# AGENTS.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 `*.xxxxxx.uncld.dev` 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
|
||||
|
||||
- `uc/`: 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)
|
||||
|
||||
- `landing/`: Landing page
|
||||
- `docs/`: User documentation
|
||||
|
||||
- **`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 uc ./cmd/uc
|
||||
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
|
||||
|
||||
- Test files and locations
|
||||
- Unit tests alongside source files (`*_test.go`)
|
||||
- Integration tests in `test/e2e/`
|
||||
- Test fixtures in `test/fixtures/`
|
||||
- Use table driven tests whenever possible
|
||||
- Use the `testify` library for assertions (e.g., `require.Equal`, `assert.Nil`)
|
||||
|
||||
### 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/uc/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`: Repository README
|
||||
|
||||
### 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.
|
||||
|
||||
## Documentation
|
||||
|
||||
Instructions when generating documentation:
|
||||
|
||||
- Use conversational language – write as if you were speaking to a friend.
|
||||
- Keep sentences simple, optimize for clarity and understanding.
|
||||
- Do not use em dashes (—) or semicolons (;) in sentences. Instead, break complex sentences into simpler ones.
|
||||
- Place the subject before the action whenever possible. Example: prefer "The function loads data" over "Data is loaded
|
||||
by the function."
|
||||
+27
-13
@@ -1,6 +1,6 @@
|
||||
ARG ALPINE_VERSION=3.20.3
|
||||
ARG ALPINE_VERSION=3.23.3
|
||||
|
||||
FROM golang:1.23.2-alpine AS uncloudd
|
||||
FROM golang:1.26.1-alpine AS uncloudd
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -13,30 +13,44 @@ 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
|
||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd ./cmd/uncloudd
|
||||
|
||||
|
||||
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
||||
RUN wget -q -O /tmp/corrosion.tar.gz \
|
||||
https://github.com/psviderski/corrosion/releases/latest/download/corrosion-aarch64-unknown-linux-gnu.tar.gz \
|
||||
ARG TARGETARCH
|
||||
ARG CORROSION_VERSION
|
||||
|
||||
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/download/v${CORROSION_VERSION}/corrosion-${CORROSION_ARCH}-unknown-linux-gnu.tar.gz" \
|
||||
&& tar -xzf /tmp/corrosion.tar.gz -C /tmp \
|
||||
&& install /tmp/corrosion /usr/local/bin/corrosion \
|
||||
&& install /tmp/corrosion /usr/bin/corrosion \
|
||||
&& rm /tmp/corrosion.tar.gz /tmp/corrosion
|
||||
|
||||
FROM chainguard/wolfi-base:latest AS corrosion
|
||||
COPY --from=corrosion-download /usr/local/bin/corrosion /usr/local/bin/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-debian13:latest AS corrosion
|
||||
COPY --from=corrosion-download /usr/bin/corrosion /usr/bin/corrosion
|
||||
CMD ["corrosion", "agent"]
|
||||
|
||||
|
||||
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
|
||||
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
|
||||
ARG CORROSION_VERSION
|
||||
ARG CORROSION_IMAGE="ghcr.io/unlabs-dev/corrosion:${CORROSION_VERSION}"
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk --no-cache add crane
|
||||
RUN crane pull "${CORROSION_IMAGE}" /corrosion.tar
|
||||
|
||||
RUN crane pull --platform ${TARGETOS}/${TARGETARCH} "${CORROSION_IMAGE}" /corrosion.tar
|
||||
|
||||
# Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker.
|
||||
FROM docker:27.3.1-dind AS ucind
|
||||
FROM docker:29.4.0-dind AS ucind
|
||||
ARG CORROSION_VERSION
|
||||
|
||||
# Create system group and user 'uncloud'.
|
||||
RUN addgroup -S uncloud && adduser -SHD -h /nonexistent -G uncloud -g "" uncloud
|
||||
RUN apk --no-cache add \
|
||||
@@ -44,7 +58,7 @@ RUN apk --no-cache add \
|
||||
wireguard-tools
|
||||
|
||||
COPY --from=corrosion-image-tarball /corrosion.tar /images/corrosion.tar
|
||||
COPY scripts/docker/dind scripts/docker/entrypoint.sh /usr/local/bin/
|
||||
COPY scripts/docker/entrypoint.sh /usr/local/bin/
|
||||
COPY --from=uncloudd /build/uncloudd /usr/local/bin/
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
# Developing Uncloud
|
||||
|
||||
A short guide on setting up your local development environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [mise](https://mise.jdx.dev/) (development environment setup tool)
|
||||
- [Docker](https://docs.docker.com/get-docker/) with BuildKit (for running end-to-end tests). Make sure the
|
||||
[`docker buildx`](https://docs.docker.com/build/concepts/overview/) plugin is installed.
|
||||
|
||||
## Setup
|
||||
|
||||
Uncloud uses `mise` to install and manage development tools and dependencies. It makes it easy to create reproducible
|
||||
development environments on developer machines and CI.
|
||||
|
||||
Follow the [Installing Mise](https://mise.jdx.dev/installing-mise.html) guide to get it set up on your machine.
|
||||
|
||||
Mark the project as trusted and install all required tools (Go, `protoc`, `golangci-lint`, etc.) by running from the
|
||||
project root:
|
||||
|
||||
```shell
|
||||
mise trust
|
||||
mise install
|
||||
```
|
||||
|
||||
This reads [`mise.toml`](mise.toml) and installs the exact versions of tools specified there. They will be available in
|
||||
your `PATH` when you are in the project directory, so you can run `go` or `golangci-lint` directly. The tools won't be
|
||||
available outside the project, so they don't interfere with system packages.
|
||||
|
||||
## Building
|
||||
|
||||
Build the CLI:
|
||||
|
||||
```shell
|
||||
go build -o uc ./cmd/uc
|
||||
```
|
||||
|
||||
Or build and run the CLI with a single command:
|
||||
|
||||
```shell
|
||||
go run ./cmd/uc --help
|
||||
```
|
||||
|
||||
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
|
||||
Windows:
|
||||
|
||||
```shell
|
||||
GOOS=linux GOARCH=amd64 go build -o uncloudd ./cmd/uncloudd
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run all tests (unit and e2e):
|
||||
|
||||
```shell
|
||||
make test
|
||||
```
|
||||
|
||||
### End-to-end tests
|
||||
|
||||
E2e tests run inside Docker using the [Uncloud-in-Docker](#uncloud-in-docker-ucind) image. Build it first:
|
||||
|
||||
```shell
|
||||
mise ucind:image
|
||||
```
|
||||
|
||||
⚠️ NOTE: You need to rebuild the `ucind` image every time you make changes to the daemon code if you want to test them
|
||||
in e2e tests.
|
||||
|
||||
Then run the tests:
|
||||
|
||||
```shell
|
||||
make test-e2e
|
||||
```
|
||||
|
||||
If tests leave leftover containers, clean them up with:
|
||||
|
||||
```shell
|
||||
make test-clean
|
||||
```
|
||||
|
||||
### Manual testing on dev machines
|
||||
|
||||
After making changes to the daemon code, you may want to test them in your dev cluster. Build and install `uncloudd` on
|
||||
one or multiple dev machines over SSH with:
|
||||
|
||||
```shell
|
||||
mise run dev:install user@host1 user@host2/arm64
|
||||
```
|
||||
|
||||
It will also restart the `uncloud.service` daemon on those machines to pick up the new version. The host format is
|
||||
`[user@]host[/arch]`. The architecture of a machine defaults to `amd64` if not specified.
|
||||
|
||||
To stop the daemon and wipe all Uncloud data on the dev machines, run:
|
||||
|
||||
```shell
|
||||
mise run dev:reset user@host1 user@host2
|
||||
```
|
||||
|
||||
## Linting and formatting
|
||||
|
||||
Lint and format the code:
|
||||
|
||||
```shell
|
||||
make lint-and-fix
|
||||
```
|
||||
|
||||
## Code generation
|
||||
|
||||
Update the generated Go code for the machine gRPC API after modifying `.proto` files:
|
||||
|
||||
```shell
|
||||
mise run proto
|
||||
```
|
||||
|
||||
Regenerate mocks:
|
||||
|
||||
```shell
|
||||
make mocks
|
||||
```
|
||||
|
||||
## Uncloud in Docker (UCinD)
|
||||
|
||||
The [`ucind`](./cmd/ucind) CLI lets you run Uncloud clusters locally using Docker containers instead of real machines.
|
||||
Each cluster machine runs as a Docker container connected to a shared Docker network.
|
||||
|
||||
Cluster machines use the `ghcr.io/psviderski/ucind:latest` Docker image by default. It may not always have the latest
|
||||
changes to the `uncloudd` daemon code as it's not automatically rebuilt on CI yet.
|
||||
|
||||
You can rebuild it locally to pick up the latest changes, including your work in progress, and test them in a UCinD
|
||||
cluster:
|
||||
|
||||
```shell
|
||||
mise ucind:image
|
||||
```
|
||||
|
||||
Create a cluster with 3 machines:
|
||||
|
||||
```shell
|
||||
mise ucind cluster create -m 3
|
||||
```
|
||||
|
||||
This also sets the new cluster as the active context, so you can interact with it using the `uc` CLI right away.
|
||||
|
||||
Remove the cluster when you're done:
|
||||
|
||||
```shell
|
||||
mise ucind cluster rm
|
||||
```
|
||||
|
||||
Sometimes crashed tests or interrupted runs can leave orphaned containers and networks behind. Clean them all up with:
|
||||
|
||||
```shell
|
||||
mise ucind:cleanup
|
||||
```
|
||||
@@ -1,44 +1,75 @@
|
||||
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
|
||||
# TODO: Makefile is deprecated, add new targets as mise tasks in mise.toml instead.
|
||||
|
||||
CORROSION_VERSION ?= 2026.6.15
|
||||
CORROSION_IMAGE = ghcr.io/unlabs-dev/corrosion:$(CORROSION_VERSION)
|
||||
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
|
||||
|
||||
update-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
|
||||
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
|
||||
ssh spy@192.168.40.176 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && \
|
||||
rm uncloudd-linux-amd64
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
|
||||
.PHONY: proto
|
||||
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: ucind-cluster
|
||||
ucind-cluster:
|
||||
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
|
||||
|
||||
.PHONY: corrosion-image
|
||||
corrosion-image:
|
||||
docker build -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||
docker build --build-arg CORROSION_VERSION=$(CORROSION_VERSION) -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||
|
||||
.PHONY: corrosion-image-push
|
||||
corrosion-image-push: corrosion-image
|
||||
docker push "$(CORROSION_IMAGE)"
|
||||
.PHONY: corrosion-multiarch-image-push
|
||||
corrosion-multiarch-image-push:
|
||||
docker buildx build --build-arg CORROSION_VERSION=$(CORROSION_VERSION) --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: ucind-image-push
|
||||
ucind-image-push: ucind-image
|
||||
docker push "$(UCIND_IMAGE)"
|
||||
.PHONY: mocks
|
||||
mocks:
|
||||
@mockery
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
ifeq ($(TEST_NAME),)
|
||||
go test -shuffle=on -count=1 -v ./...
|
||||
else
|
||||
go test -shuffle=on -race -count=1 -v -run ^$(TEST_NAME)$$ ./...
|
||||
endif
|
||||
|
||||
.PHONY: test-e2e
|
||||
test-e2e:
|
||||
go test -shuffle=on -race -count=1 -v ./test/e2e
|
||||
|
||||
.PHONY: test-clean
|
||||
test-clean:
|
||||
@CONTAINERS=$$(docker ps --filter "label=ucind.managed" -q); \
|
||||
if [ -n "$$CONTAINERS" ]; then \
|
||||
echo "Killing containers..."; \
|
||||
docker kill $$CONTAINERS; \
|
||||
fi; \
|
||||
CONTAINERS_STOPPED=$$(docker ps -a --filter "label=ucind.managed" -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/uc docs
|
||||
|
||||
@@ -1,48 +1,74 @@
|
||||
<div align="center">
|
||||
<img src="./website/images/logo.svg" height="100" alt="Uncloud logo"/>
|
||||
<h1>Uncloud</h1>
|
||||
<p><strong>Docker simplicity. Multi-machine power.</strong></p>
|
||||
<img src="./website/landing/images/logo-title.svg#gh-light-mode-only" alt="Uncloud logo"/>
|
||||
<img src="./website/landing/images/logo-title-dark.svg#gh-dark-mode-only" alt="Uncloud logo"/>
|
||||
<p><strong>▸ Deploy and scale containerised apps across servers without Swarm or Kubernetes overhead ◂</strong></p>
|
||||
|
||||
<p>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Uncloud is a lightweight clustering and container orchestration tool that lets you deploy and manage web apps across
|
||||
cloud VMs and bare metal with minimized cluster management overhead. It creates a secure WireGuard mesh network between
|
||||
cloud VMs and bare metal with minimised cluster management overhead. It creates a secure WireGuard mesh network between
|
||||
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
|
||||
synchronized copy of the cluster state through peer-to-peer communication, keeping cluster operations functional even if
|
||||
synchronised copy of the cluster state through peer-to-peer communication, keeping cluster operations functional even if
|
||||
some machines go offline.
|
||||
|
||||
Uncloud aims to be the solution for developers who want the flexibility of self-hosted infrastructure without the
|
||||
operational complexity of Kubernetes.
|
||||
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 —
|
||||
* **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.
|
||||
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Build and push your Docker images directly to
|
||||
your machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
|
||||
* **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 decentralized design eliminates single points of failure and reduces operational overhead.
|
||||
* **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.
|
||||
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
||||
* **Managed DNS**: Automatic DNS records `*.xxxxxx.uncld.dev` 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:
|
||||
## 🎬 Quick demo
|
||||
|
||||
* Project isolation through environments/namespaces
|
||||
* Infrastructure as Code using Docker Compose format
|
||||
* Persistent volumes and secrets management
|
||||
* Monitoring and log aggregation
|
||||
* Database deployment and management
|
||||
* Curated application catalog
|
||||
The screenshot below demonstrates how we use Uncloud to deploy https://uncloud.run website to 2 remote machines from
|
||||
the [`compose.yaml`](website/compose.yaml) file on the local machine.
|
||||
|
||||
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
||||
It exposes the container port `8000/tcp` as HTTPS on `uncloud.run` domain, 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/4fb71ea442905b81b1ad4e25fc02132e.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>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
> **📚 Want more examples?** Check out the [**uncloud-recipes**](https://github.com/psviderski/uncloud-recipes)
|
||||
> repository for community recipes and templates for deploying popular services on Uncloud.
|
||||
|
||||
## 💫 Why Uncloud?
|
||||
|
||||
@@ -52,49 +78,116 @@ believe there's a sweet spot in between — a pragmatic solution for the majorit
|
||||
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 maintaining the cloud-like experience you love.
|
||||
* **Stay simple**: Don't worry about control planes, highly-available clusters, or complex YAML configurations for
|
||||
common use cases.
|
||||
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** 🌿: Minimize system overhead to maximize resources available for your applications.
|
||||
* **Support sustainable computing** 🌿: Minimise system overhead to maximise resources available for your applications.
|
||||
|
||||
Uncloud's goal is to make deployment and management of containerized applications feel as seamless as using a cloud
|
||||
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
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
```bash
|
||||
brew install psviderski/tap/uncloud
|
||||
|
||||
2. Initialize your first machine:
|
||||
# or using curl (macOS/Linux)
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
uc machine init root@your-server-ip
|
||||
```
|
||||
See [Installation](https://uncloud.run/docs/getting-started/install-cli) for more options.
|
||||
|
||||
3. Create a DNS A record in your domain registrar that points `app.example.com` to your server's IP address. Allow a few
|
||||
minutes for DNS propagation.
|
||||
4. Deploy your app from a Docker image:
|
||||
There's also a [nightly rolling release](https://github.com/psviderski/uncloud/releases/tag/nightly) available if you
|
||||
want to try the latest features and fixes before they're included in an official release.
|
||||
|
||||
```bash
|
||||
uc run -p app.example.com:8000/https my-app-image
|
||||
```
|
||||
2. Initialise your first machine:
|
||||
|
||||
That's it! Your app is now running and accessible at https://app.example.com ✨
|
||||
```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](docs/design.md) to understand Uncloud's design philosophy and goals. Here, let's peek
|
||||
under the hood to see what happens when you run certain commands.
|
||||
Check out the [design document](misc/design.md) to understand Uncloud's design philosophy and goals.
|
||||
|
||||
**When you initialize a new cluster on a machine:**
|
||||
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 root@your-server-ip
|
||||
$ 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
|
||||
@@ -110,7 +203,42 @@ uc machine init root@your-server-ip
|
||||
**When you add another machine:**
|
||||
|
||||
```bash
|
||||
uc machine add ubuntu@second-server-ip
|
||||
$ 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.
|
||||
@@ -129,7 +257,14 @@ and automatically establish a WireGuard tunnel with it.
|
||||
**When you run a service:**
|
||||
|
||||
```bash
|
||||
uc run -p app.example.com:8000/https my-app-image
|
||||
$ 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.
|
||||
@@ -147,20 +282,84 @@ Look ma, no control plane or master nodes to maintain! Just a simple overlay net
|
||||
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>
|
||||
|
||||
## 🧪 Interactive tutorials
|
||||
|
||||
To give you a chance to play with Uncloud without even leaving your browser, we're providing interactive tutorials and
|
||||
playgrounds on the [iximiuz Labs](https://labs.iximiuz.com/) platform.
|
||||
|
||||
Available tutorials:
|
||||
|
||||
1. [Setting up a new Uncloud cluster](https://labs.iximiuz.com/tutorials/uncloud-create-cluster-ebebf72b) - the tutorial
|
||||
walks you through creating a new cluster with two machines and then deploying a simple web service to it.
|
||||
2. [Deploying a Django web application](https://labs.iximiuz.com/tutorials/uncloud-deploy-django-app-7a378bc3) - deploy
|
||||
a Django web app from source code to a remote Linux server using Uncloud.
|
||||
|
||||
You can also launch the [Uncloud playground](https://labs.iximiuz.com/playgrounds/uncloud-cluster-64523f7c) where you
|
||||
can play with an already initialized Uncloud cluster.
|
||||
|
||||
## 🏗 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.
|
||||
Uncloud is under active development and pre-1.0, so expect occasional breaking changes between releases. The core build,
|
||||
push, and deploy workflows are stable, and much of the runtime relies on battle-tested projects: Docker, Caddy,
|
||||
WireGuard, and Corrosion.
|
||||
|
||||
I'd love your input! Here's how you can contribute:
|
||||
We'd love your input! Here's how you can contribute:
|
||||
|
||||
* 🐛 Found a bug? [Open an issue](https://github.com/psviderski/uncloud/issues)
|
||||
* 💡 Have ideas? [Join the discussion](https://github.com/psviderski/uncloud/discussions)
|
||||
* 💡 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
|
||||
|
||||
* [Subscribe](https://uncloud.run/#subscribe) to my newsletter to follow the progress, get early insights into new
|
||||
features, and be the first to know when it's ready for production use.
|
||||
* 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 [our newsletter](https://uncloud.run/#subscribe) to follow the journey of building Uncloud: progress
|
||||
updates, user stories, and the occasional deep dive.
|
||||
* Watch this repository for releases.
|
||||
* Follow [@psviderski](https://github.com/psviderski) on GitHub.
|
||||
|
||||
## 💖 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
|
||||
|
||||
<a href="https://trendshift.io/repositories/14069">
|
||||
<img alt="Trendshift" src="https://trendshift.io/api/badge/repositories/14069" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=psviderski/uncloud" />
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
_ _ ___
|
||||
| | | |/ __|
|
||||
| |_| | (__
|
||||
\__,_|\___|
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type buildOptions struct {
|
||||
cli.BuildServicesOptions
|
||||
files []string
|
||||
profiles []string
|
||||
}
|
||||
|
||||
// NewBuildCommand creates a new command to build images for services from a Compose file.
|
||||
func NewBuildCommand() *cobra.Command {
|
||||
opts := buildOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "build [FLAGS] [SERVICE...]",
|
||||
Short: "Build services from a Compose file.",
|
||||
Long: `Build images for services from a Compose file using local Docker.
|
||||
|
||||
By default, built images remain on the local Docker host. Use --push to upload them
|
||||
to cluster machines or --push-registry to upload them to external registries.`,
|
||||
Example: ` # Build all services that have a build section in compose.yaml.
|
||||
uc build
|
||||
|
||||
# Build specific services that have a build section.
|
||||
uc build web api
|
||||
|
||||
# Build services and push images to all cluster machines or service x-machines if specified.
|
||||
uc build --push
|
||||
|
||||
# Build services and push images to specific machines.
|
||||
uc build --push -m machine1,machine2
|
||||
|
||||
# Build services and push images to external registries (e.g., Docker Hub).
|
||||
uc build --push-registry
|
||||
|
||||
# Build services with build arguments, pull newer base images before building, and don't use cache.
|
||||
uc build --build-arg NODE_VERSION=24 --build-arg ENV=production --no-cache --pull`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.Services = args
|
||||
|
||||
return runBuild(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: "service",
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
return completion.ComposeServices(cmd.Context(), args, toComplete, opts.files, opts.profiles)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil,
|
||||
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
|
||||
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false,
|
||||
"Check the build configuration for services without building them.")
|
||||
cmd.Flags().BoolVar(&opts.Deps, "deps", false,
|
||||
"Also build services declared as dependencies of the selected services.")
|
||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||
"One or more Compose files to build. (default compose.yaml)")
|
||||
cmd.Flags().StringSliceVarP(&opts.Machines, "machine", "m", nil,
|
||||
"Machine names or IDs to push the built images to (requires --push).\n"+
|
||||
"Can be specified multiple times or as a comma-separated list. (default is all machines or x-machines)")
|
||||
cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false,
|
||||
"Do not use cache when building images.")
|
||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||
"One or more Compose profiles to enable.")
|
||||
cmd.Flags().BoolVar(&opts.Pull, "pull", false,
|
||||
"Always attempt to pull newer versions of base images before building.")
|
||||
cmd.Flags().BoolVar(&opts.PushCluster, "push", false,
|
||||
"Upload the built images to cluster machines after building.\n"+
|
||||
"Use --machine to specify which machines. (default is all machines)")
|
||||
cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false,
|
||||
"Upload the built images to external registries (e.g., Docker Hub) after building.")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runBuild parses the Compose file(s) and builds the images for selected services.
|
||||
func runBuild(ctx context.Context, uncli *cli.CLI, opts buildOptions) error {
|
||||
// Validate push flags.
|
||||
if opts.PushCluster && opts.PushRegistry {
|
||||
return fmt.Errorf("cannot specify both --push and --push-registry: choose one push target")
|
||||
}
|
||||
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.Machines)
|
||||
// Special handling for an explicit "all" keyword to push to all machines.
|
||||
if len(machines) == 1 && machines[0] == "all" {
|
||||
machines = nil
|
||||
}
|
||||
opts.Machines = machines
|
||||
|
||||
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load compose file(s): %w", err)
|
||||
}
|
||||
|
||||
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
|
||||
|
||||
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("determine services to build: %w", err)
|
||||
}
|
||||
if len(servicesToBuild) == 0 {
|
||||
fmt.Println("No services to build.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return uncli.BuildServices(ctx, project, opts.BuildServicesOptions)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/alecthomas/chroma/v2/quick"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type configOptions struct {
|
||||
machine string
|
||||
noColor bool
|
||||
}
|
||||
|
||||
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.")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
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 = clusterClient.ProxySingleMachineContext(ctx, opts.machine)
|
||||
}
|
||||
|
||||
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,242 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"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
|
||||
}
|
||||
|
||||
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 or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||
"list. (default is all machines)")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
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)
|
||||
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.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(client.CaddyServiceName) +
|
||||
tui.Faint.Render(" (not running)"))
|
||||
} else {
|
||||
fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(svc.Name) +
|
||||
tui.Faint.Render(" ("+svc.Mode+" 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 {
|
||||
formattedImages := make([]string, len(currentImages))
|
||||
for i, img := range currentImages {
|
||||
formattedImages[i] = tui.FormatImage(img, tui.NoStyle)
|
||||
}
|
||||
fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") +
|
||||
strings.Join(formattedImages, tui.Faint.Render(", ")))
|
||||
} else {
|
||||
fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], tui.NoStyle))
|
||||
}
|
||||
}
|
||||
|
||||
if opts.image != "" {
|
||||
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green))
|
||||
}
|
||||
|
||||
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.Println(tui.Faint.Render("target image: ") + tui.FormatImage(d.Spec.Container.Image,
|
||||
tui.Green) + tui.Faint.Render(" (latest stable)"))
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
|
||||
fmt.Println()
|
||||
|
||||
directConn := uncli.DirectConnection()
|
||||
contextName := uncli.ContextOverrideOrCurrent()
|
||||
deployTarget := ""
|
||||
if directConn != "" {
|
||||
deployTarget = directConn
|
||||
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
|
||||
fmt.Println()
|
||||
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
|
||||
// Only show context if there's more than one to avoid unnecessary clutter.
|
||||
deployTarget = contextName
|
||||
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println(plan.Format())
|
||||
|
||||
summary := plan.FormatSummary()
|
||||
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
|
||||
fmt.Println(summary)
|
||||
fmt.Println()
|
||||
|
||||
title := "Proceed with deployment?"
|
||||
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
|
||||
// deploying to the wrong cluster.
|
||||
if deployTarget != "" {
|
||||
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
|
||||
title = "Proceed with deployment to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
|
||||
}
|
||||
|
||||
confirmed, err := tui.Confirm(title)
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm deployment: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
return cli.Cancelled("Caddy deploy cancelled. No changes were made.")
|
||||
}
|
||||
|
||||
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,29 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"github.com/psviderski/uncloud/cmd/uc/service"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewLogsCommand() *cobra.Command {
|
||||
var options logs.Options
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs",
|
||||
Aliases: []string{"log"},
|
||||
Short: "View caddy logs.",
|
||||
Long: `View caddy logs.
|
||||
|
||||
This calls "uc logs caddy", see "uc logs" for the documention.
|
||||
`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
args = append([]string{"caddy"}, args...)
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return service.RunLogs(cmd.Context(), uncli, args, options)
|
||||
},
|
||||
}
|
||||
cmd.Flags().AddFlagSet(logs.Flags(&options))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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(),
|
||||
NewLogsCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCommandArgsValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
newCmd func() *cobra.Command
|
||||
args []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "root accepts no args",
|
||||
newCmd: NewRootCommand,
|
||||
args: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "root rejects extra args",
|
||||
newCmd: NewRootCommand,
|
||||
args: []string{"extra"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "list accepts no args",
|
||||
newCmd: NewListCommand,
|
||||
args: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "list rejects extra args",
|
||||
newCmd: NewListCommand,
|
||||
args: []string{"extra"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "show accepts no args",
|
||||
newCmd: NewShowCommand,
|
||||
args: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "show rejects extra args",
|
||||
newCmd: NewShowCommand,
|
||||
args: []string{"extra"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "connection accepts no args",
|
||||
newCmd: NewConnectionCommand,
|
||||
args: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "connection rejects extra args",
|
||||
newCmd: NewConnectionCommand,
|
||||
args: []string{"extra"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "use accepts no args",
|
||||
newCmd: NewUseCommand,
|
||||
args: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "use accepts one arg",
|
||||
newCmd: NewUseCommand,
|
||||
args: []string{"prod"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "use rejects extra args",
|
||||
newCmd: NewUseCommand,
|
||||
args: []string{"prod", "extra"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := tt.newCmd()
|
||||
require.NotNil(t, cmd.Args)
|
||||
|
||||
err := cmd.Args(cmd, tt.args)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewConnectionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "connection",
|
||||
Aliases: []string{"conn"},
|
||||
Short: "Choose a new default connection for the current context.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return selectConnection(uncli)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func selectConnection(uncli *cli.CLI) error {
|
||||
if uncli.Config == nil {
|
||||
return fmt.Errorf("connection 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())
|
||||
}
|
||||
|
||||
currentCtxName := uncli.Config.CurrentContext
|
||||
currentCtx, ok := uncli.Config.Contexts[currentCtxName]
|
||||
if !ok {
|
||||
return fmt.Errorf("current context '%s' not found", currentCtxName)
|
||||
}
|
||||
|
||||
if len(currentCtx.Connections) == 0 {
|
||||
return fmt.Errorf("no connections found in context '%s'", currentCtxName)
|
||||
}
|
||||
|
||||
var selectedConnection int
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select a default connection").
|
||||
Options(buildConnectionOptions(currentCtx.Connections)...).
|
||||
Value(&selectedConnection),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("select connection: %w", err)
|
||||
}
|
||||
|
||||
currentCtx.SetDefaultConnection(selectedConnection)
|
||||
selected := currentCtx.Connections[0]
|
||||
|
||||
if err := uncli.Config.Save(); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Default connection for context '%s' is now '%s'.\n", currentCtxName, selected.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildConnectionOptions(connections []config.MachineConnection) []huh.Option[int] {
|
||||
options := make([]huh.Option[int], len(connections))
|
||||
for i, conn := range connections {
|
||||
key := conn.String()
|
||||
opt := huh.NewOption(key, i)
|
||||
if i == 0 {
|
||||
opt.Key += " (default)"
|
||||
opt = opt.Selected(true)
|
||||
}
|
||||
options[i] = opt
|
||||
}
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List available cluster contexts.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(uncli)
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
t := tui.NewTable()
|
||||
t.Headers("NAME", "CURRENT", "CONNECTIONS")
|
||||
|
||||
for _, name := range contextNames {
|
||||
current := ""
|
||||
if name == currentContext {
|
||||
current = "✓"
|
||||
}
|
||||
connCount := len(uncli.Config.Contexts[name].Connections)
|
||||
t.Row(name, current, fmt.Sprintf("%d", connCount))
|
||||
}
|
||||
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return selectContext(uncli)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
NewListCommand(),
|
||||
NewUseCommand(),
|
||||
NewConnectionCommand(),
|
||||
NewShowCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewShowCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show current cluster context.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return show(uncli)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func show(uncli *cli.CLI) error {
|
||||
// discard errors, only show the current context, otherwise nothing
|
||||
if uncli.Config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(uncli.Config.Contexts) == 0 {
|
||||
return nil
|
||||
}
|
||||
fmt.Println(uncli.Config.CurrentContext)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"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.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
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)
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Contexts(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
if !tui.IsTerminalAvailable() {
|
||||
return fmt.Errorf("cannot select a context interactively without a terminal. " +
|
||||
"Pass the context name explicitly: uc ctx use CONTEXT")
|
||||
}
|
||||
|
||||
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,313 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"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/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type deployOptions struct {
|
||||
cli.BuildServicesOptions
|
||||
|
||||
files []string
|
||||
profiles []string
|
||||
services []string
|
||||
noBuild bool
|
||||
recreate bool
|
||||
skipHealth bool
|
||||
yes bool
|
||||
}
|
||||
|
||||
// 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 {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.services = args
|
||||
|
||||
return runDeploy(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: "service",
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
return completion.ComposeServices(cmd.Context(), args, toComplete, opts.files, opts.profiles)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil,
|
||||
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
|
||||
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
|
||||
cmd.Flags().BoolVar(&opts.BuildServicesOptions.Pull, "build-pull", false,
|
||||
"Always attempt to pull newer versions of base images before building service images.")
|
||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||
"One or more Compose files to deploy services from. (default compose.yaml)")
|
||||
cmd.Flags().BoolVar(&opts.noBuild, "no-build", false,
|
||||
"Do not build new images before deploying services.")
|
||||
cmd.Flags().BoolVar(&opts.BuildServicesOptions.NoCache, "no-cache", false,
|
||||
"Do not use cache when building images.")
|
||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||
"One or more Compose profiles to enable.")
|
||||
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
||||
"Recreate containers even if their configuration and image haven't changed.")
|
||||
cmd.Flags().BoolVar(&opts.skipHealth, "skip-health", false,
|
||||
"Skip the monitoring period and health checks after starting new containers. Useful for faster emergency "+
|
||||
"deployments.\n"+
|
||||
"Warning: This may cause downtime if new containers fail to start properly.")
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+
|
||||
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// runDeploy parses the Compose file(s) and deploys the services.
|
||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load compose file(s): %w", err)
|
||||
}
|
||||
|
||||
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
|
||||
|
||||
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, err := cli.ServicesThatNeedBuild(project, opts.services, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("determine services to build: %w", err)
|
||||
}
|
||||
|
||||
if len(servicesToBuild) > 0 {
|
||||
if opts.noBuild {
|
||||
fmt.Println("Not building services as requested.")
|
||||
} else {
|
||||
// Build service images without pushing them to cluster yet to not connect to the cluster twice.
|
||||
opts.BuildServicesOptions.Deps = true // build dependencies as deploy includes them by default
|
||||
opts.BuildServicesOptions.Services = opts.services
|
||||
|
||||
if err = uncli.BuildServices(ctx, project, opts.BuildServicesOptions); err != nil {
|
||||
return fmt.Errorf("build services: %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
if len(servicesToBuild) > 0 && !opts.noBuild {
|
||||
// Push built service images to cluster machines one at a time.
|
||||
var errs []error
|
||||
for _, s := range servicesToBuild {
|
||||
if s.Image == "" {
|
||||
// Skip services without an image name (shouldn't happen for services with build config).
|
||||
continue
|
||||
}
|
||||
|
||||
// Push to the specified x-machines or to *all* cluster machines if not specified.
|
||||
var pushOpts client.PushImageOptions
|
||||
if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
|
||||
pushOpts.Machines = machines
|
||||
}
|
||||
if len(pushOpts.Machines) == 0 {
|
||||
pushOpts.AllMachines = true
|
||||
}
|
||||
|
||||
boldStyle := lipgloss.NewStyle().Bold(true)
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
|
||||
return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
|
||||
// Collect errors to try pushing all images.
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = errors.Join(errs...); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Resolve 'secret://name' references to actual secret values before creating a deployment.
|
||||
if compose.HasCommandSecretRefs(project) {
|
||||
fmt.Fprintln(os.Stderr, "Resolving secrets...")
|
||||
}
|
||||
if err = compose.ResolveSecrets(ctx, project); err != nil {
|
||||
return fmt.Errorf("resolve secrets: %w", err)
|
||||
}
|
||||
|
||||
strategy := &deploy.RollingStrategy{
|
||||
ForceRecreate: opts.recreate,
|
||||
SkipHealthMonitor: opts.skipHealth,
|
||||
}
|
||||
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 plan.IsEmpty() {
|
||||
fmt.Println("Services are up to date.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
|
||||
fmt.Println()
|
||||
|
||||
directConn := uncli.DirectConnection()
|
||||
contextName := uncli.ContextOverrideOrCurrent()
|
||||
deployTarget := ""
|
||||
if directConn != "" {
|
||||
deployTarget = directConn
|
||||
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
|
||||
fmt.Println()
|
||||
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
|
||||
// Only show context if there's more than one to avoid unnecessary clutter.
|
||||
deployTarget = contextName
|
||||
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println(plan.Format())
|
||||
|
||||
// Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
|
||||
if !opts.yes {
|
||||
if !tui.IsTerminalAvailable() {
|
||||
return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " +
|
||||
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
|
||||
}
|
||||
|
||||
title := "Proceed with deployment?"
|
||||
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
|
||||
// deploying to the wrong cluster.
|
||||
if deployTarget != "" {
|
||||
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
|
||||
title = "Proceed with deployment to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
|
||||
}
|
||||
|
||||
confirmed, err := tui.Confirm(title)
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm deployment: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
return cli.Cancelled("Deploy cancelled. No changes were made.")
|
||||
}
|
||||
}
|
||||
|
||||
title := "Deploying"
|
||||
if deployTarget != "" {
|
||||
title += " to " + tui.NameStyle.Render(deployTarget)
|
||||
}
|
||||
err = 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(), title)
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
|
||||
tail := failedContainerLogsTail()
|
||||
if hookErr, ok := errors.AsType[*operation.PreDeployHookError](err); ok {
|
||||
printFailedContainerLogs(ctx, clusterClient,
|
||||
hookErr.ServiceName, hookErr.ContainerID, hookErr.MachineName, tail,
|
||||
fmt.Sprintf("Last %d log lines from failed pre-deploy hook:", tail))
|
||||
fmt.Println()
|
||||
} else if startErr, ok := errors.AsType[*operation.ContainerHealthError](err); ok {
|
||||
printFailedContainerLogs(ctx, clusterClient,
|
||||
startErr.ServiceName, startErr.ContainerID, startErr.MachineName, tail,
|
||||
fmt.Sprintf("Last %d log lines from failed container:", tail))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printFailedContainerLogs fetches the last tail log lines from a container that failed during deployment and prints
|
||||
// them using the standard log formatter under the provided header.
|
||||
func printFailedContainerLogs(
|
||||
ctx context.Context, cli *client.Client, serviceName, containerID, machineName string, tail int, header string,
|
||||
) {
|
||||
_, ch, err := cli.ServiceLogs(ctx, serviceName, api.ServiceLogsOptions{
|
||||
Containers: []string{containerID},
|
||||
Machines: []string{machineName},
|
||||
Tail: tail,
|
||||
})
|
||||
if err != nil {
|
||||
shortCtrID := stringid.TruncateID(containerID)
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch container logs '%s/%s': %v\n", serviceName, shortCtrID, err)
|
||||
fmt.Fprintf(os.Stderr, "You can try manually with: uc logs %s/%s\n", serviceName, shortCtrID)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(tui.BoldRed.Render(header))
|
||||
|
||||
logsEmpty := true
|
||||
formatter := logs.NewFormatter([]string{machineName}, []string{serviceName}, false)
|
||||
for entry := range ch {
|
||||
logsEmpty = false
|
||||
formatter.PrintEntry(entry)
|
||||
}
|
||||
|
||||
if logsEmpty {
|
||||
fmt.Println("<no logs available>")
|
||||
}
|
||||
}
|
||||
|
||||
// defaultFailedContainerLogsTail is the default number of recent log lines to print from a failed container to give
|
||||
// the user immediate context without requiring a follow-up 'uc logs' invocation.
|
||||
// Overridable via UNCLOUD_FAILED_CONTAINER_LOGS_TAIL.
|
||||
const defaultFailedContainerLogsTail = 10
|
||||
|
||||
// failedContainerLogsTail returns the number of log lines to fetch from a failed container, honouring the
|
||||
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
|
||||
func failedContainerLogsTail() int {
|
||||
if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" {
|
||||
if tail, err := logs.Tail(v); err == nil && (tail == -1 || tail > 0) {
|
||||
return tail
|
||||
}
|
||||
}
|
||||
return defaultFailedContainerLogsTail
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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"
|
||||
)
|
||||
|
||||
func NewReleaseCommand() *cobra.Command {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func release(ctx context.Context, uncli *cli.CLI) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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,70 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/cmd/uc/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
|
||||
}
|
||||
|
||||
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.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
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 'xxxxxx.uncld.dev' 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,43 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewShowCommand() *cobra.Command {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func show(ctx context.Context, uncli *cli.CLI) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
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
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
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 docOptions struct {
|
||||
manual bool
|
||||
}
|
||||
|
||||
type cmdWrapper struct {
|
||||
cmd *cobra.Command
|
||||
}
|
||||
|
||||
// NewDocsCommand creates a new hidden command to generate CLI reference docs.
|
||||
func NewDocsCommand() *cobra.Command {
|
||||
wrapper := &cmdWrapper{}
|
||||
opts := docOptions{}
|
||||
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 {
|
||||
if opts.manual {
|
||||
header := &doc.GenManHeader{
|
||||
Title: "Uncloud",
|
||||
Section: "1",
|
||||
Source: "Uncloud https://uncloud.run",
|
||||
}
|
||||
if err := doc.GenManTree(cmd.Root(), header, "."); err != nil {
|
||||
return fmt.Errorf("generate CLI manual pages: %w", err)
|
||||
}
|
||||
completionFiles, err := filepath.Glob("uc-completion*.1")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list generated manual pages: %w", err)
|
||||
}
|
||||
for _, f := range completionFiles {
|
||||
if err = os.Remove(f); err != nil {
|
||||
return fmt.Errorf("remove '%s': %w", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.manual, "manual", false,
|
||||
"Generate Uncloud manual pages in the current directory.")
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/go-units"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listOptions struct {
|
||||
machines []string
|
||||
nameFilter string
|
||||
}
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
opts := listOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls [REPO:[TAG]]",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List images on machines in the cluster.",
|
||||
Long: "List images on machines in the cluster. By default, on all machines. Optionally filter by image name.",
|
||||
Example: ` # List all images on all machines.
|
||||
uc image ls
|
||||
|
||||
# List images on specific machine.
|
||||
uc image ls -m machine1
|
||||
|
||||
# List images on multiple machines.
|
||||
uc image ls -m machine1,machine2
|
||||
|
||||
# List images filtered by name (with any tag) on all machines.
|
||||
uc image ls myapp
|
||||
|
||||
# List images filtered by name pattern on specific machine.
|
||||
uc image ls "myapp:1.*" -m machine1`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.nameFilter = args[0]
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
|
||||
"(default is include all machines)")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// imageRow represents a single image with its metadata for display.
|
||||
type imageRow struct {
|
||||
id string
|
||||
name string
|
||||
platforms string
|
||||
createdHuman string
|
||||
createdUnix int64
|
||||
size string
|
||||
inUse string
|
||||
store string
|
||||
machine string
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
|
||||
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
|
||||
Machines: machines,
|
||||
Name: opts.nameFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list images: %w", err)
|
||||
}
|
||||
|
||||
// Collect all images from all machines.
|
||||
var rows []imageRow
|
||||
|
||||
for _, machineImages := range clusterImages {
|
||||
if err := machineImages.Error(); err != nil {
|
||||
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
|
||||
machineImages.Metadata.MachineName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Get machine name for better readability.
|
||||
machineName := machineImages.Metadata.MachineName
|
||||
|
||||
store := "docker"
|
||||
if machineImages.ContainerdStore {
|
||||
store = "containerd"
|
||||
}
|
||||
|
||||
// Process each image for this machine.
|
||||
for _, img := range machineImages.Images {
|
||||
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
|
||||
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
|
||||
|
||||
name := "<none>"
|
||||
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
|
||||
name = img.RepoTags[0]
|
||||
}
|
||||
|
||||
imgPlatforms, _ := imagePlatforms(img)
|
||||
formattedPlatforms := formatPlatforms(imgPlatforms)
|
||||
|
||||
created := ""
|
||||
createdAt := time.Unix(img.Created, 0)
|
||||
if !createdAt.IsZero() {
|
||||
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
||||
}
|
||||
|
||||
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
|
||||
|
||||
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
|
||||
inUse := "-"
|
||||
if img.Containers != -1 { // -1 means the info is not available.
|
||||
if img.Containers > 0 {
|
||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
|
||||
} else {
|
||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, imageRow{
|
||||
id: id,
|
||||
name: name,
|
||||
platforms: formattedPlatforms,
|
||||
createdHuman: created,
|
||||
createdUnix: img.Created,
|
||||
size: size,
|
||||
inUse: inUse,
|
||||
store: store,
|
||||
machine: machineName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if opts.nameFilter != "" {
|
||||
fmt.Printf("No images matching '%s' found.\n", opts.nameFilter)
|
||||
} else {
|
||||
fmt.Println("No images found.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort images by name, then by machine name.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].name != rows[j].name {
|
||||
return rows[i].name < rows[j].name
|
||||
}
|
||||
return rows[i].machine < rows[j].machine
|
||||
})
|
||||
|
||||
// Print the images in a table format.
|
||||
lipgloss.Println(formatImageTable(rows))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
|
||||
func imagePlatforms(img image.Summary) ([]string, bool) {
|
||||
var formattedPlatforms []string
|
||||
multiPlatform := false
|
||||
|
||||
for _, m := range img.Manifests {
|
||||
if m.Kind != image.ManifestKindImage || !m.Available {
|
||||
continue
|
||||
}
|
||||
|
||||
if m.ID != img.ID {
|
||||
// There is an image manifest that has digest different from the main image digest.
|
||||
// This means the image manifest is an index or a manifest list (multi-platform image).
|
||||
multiPlatform = true
|
||||
}
|
||||
formattedPlatforms = append(formattedPlatforms, platforms.Format(m.ImageData.Platform))
|
||||
}
|
||||
|
||||
slices.Sort(formattedPlatforms)
|
||||
|
||||
return formattedPlatforms, multiPlatform
|
||||
}
|
||||
|
||||
func formatPlatforms(platforms []string) string {
|
||||
if len(platforms) == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
platformStyle := lipgloss.NewStyle().
|
||||
BorderForeground(lipgloss.Color("152")).
|
||||
Foreground(lipgloss.Color("0")).
|
||||
Background(lipgloss.Color("152"))
|
||||
// Use fancy pill borders only if the output is a terminal with color support.
|
||||
if colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ASCII {
|
||||
platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true)
|
||||
}
|
||||
|
||||
styledPlatforms := make([]string, len(platforms))
|
||||
for i, p := range platforms {
|
||||
styledPlatforms[i] = platformStyle.Render(p)
|
||||
}
|
||||
|
||||
return strings.Join(styledPlatforms, " ")
|
||||
}
|
||||
|
||||
func formatImageTable(rows []imageRow) string {
|
||||
columns := []struct {
|
||||
name string
|
||||
hide bool
|
||||
}{
|
||||
{name: "IMAGE ID"},
|
||||
{name: "NAME"},
|
||||
{name: "PLATFORMS"},
|
||||
{name: "CREATED"},
|
||||
{name: "SIZE"},
|
||||
{name: "IN USE"},
|
||||
{name: "STORE"},
|
||||
{name: "MACHINE"},
|
||||
}
|
||||
|
||||
// Hide the "IN USE" column if none of the images have that info available.
|
||||
inUseInfoAvailable := slices.ContainsFunc(rows, func(r imageRow) bool {
|
||||
return r.inUse != "-"
|
||||
})
|
||||
if !inUseInfoAvailable {
|
||||
// Hide "IN USE" column.
|
||||
columns[5].hide = true
|
||||
}
|
||||
|
||||
// Hide the "STORE" column if all machines use the containerd store.
|
||||
hasNonContainerd := slices.ContainsFunc(rows, func(r imageRow) bool {
|
||||
return r.store != "containerd"
|
||||
})
|
||||
if !hasNonContainerd {
|
||||
columns[6].hide = true
|
||||
}
|
||||
|
||||
t := tui.NewTable()
|
||||
|
||||
var headers []string
|
||||
for _, col := range columns {
|
||||
if !col.hide {
|
||||
headers = append(headers, col.name)
|
||||
}
|
||||
}
|
||||
t.Headers(headers...)
|
||||
|
||||
for _, row := range rows {
|
||||
values := []string{
|
||||
row.id,
|
||||
tui.FormatImage(row.name, tui.NoStyle),
|
||||
row.platforms,
|
||||
row.createdHuman,
|
||||
row.size,
|
||||
row.inUse,
|
||||
row.store,
|
||||
row.machine,
|
||||
}
|
||||
var filteredValues []string
|
||||
for i, v := range values {
|
||||
if !columns[i].hide {
|
||||
filteredValues = append(filteredValues, v)
|
||||
}
|
||||
}
|
||||
t.Row(filteredValues...)
|
||||
}
|
||||
|
||||
return t.String()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type pushOptions struct {
|
||||
image string
|
||||
machines []string
|
||||
platform string
|
||||
}
|
||||
|
||||
func NewPushCommand() *cobra.Command {
|
||||
opts := pushOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "push IMAGE",
|
||||
Short: "Upload a local Docker image to the cluster.",
|
||||
Long: `Upload a local Docker image to the cluster transferring only the missing layers.
|
||||
The image is uploaded to all cluster machines (default) or the specified machine(s).`,
|
||||
Example: ` # Push image to all machines in the cluster.
|
||||
uc image push myapp:latest
|
||||
|
||||
# Push image to specific machine.
|
||||
uc image push myapp:latest -m machine1
|
||||
|
||||
# Push image to multiple machines.
|
||||
uc image push myapp:latest -m machine1,machine2,machine3
|
||||
|
||||
# Push a specific platform of a multi-platform image.
|
||||
uc image push myapp:latest --platform linux/amd64`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
opts.image = args[0]
|
||||
return push(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Machine names or IDs to push the image to. Can be specified multiple times or as a comma-separated list. "+
|
||||
"(default is all machines)")
|
||||
cmd.Flags().StringVar(
|
||||
&opts.platform, "platform", "",
|
||||
"Push a specific platform of a multi-platform image (e.g., linux/amd64, linux/arm64).\n"+
|
||||
"Local Docker must be configured to use containerd image store to support multi-platform images.",
|
||||
)
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
pushOpts := client.PushImageOptions{}
|
||||
|
||||
// Special handling for an explicit "all" keyword to push to all machines.
|
||||
if len(machines) == 1 && machines[0] == "all" {
|
||||
pushOpts.AllMachines = true
|
||||
} else if len(machines) > 0 {
|
||||
pushOpts.Machines = machines
|
||||
} else {
|
||||
// Default is to push to all machines in the cluster.
|
||||
pushOpts.AllMachines = true
|
||||
}
|
||||
|
||||
if opts.platform != "" {
|
||||
p, err := platforms.Parse(opts.platform)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid platform '%s': %w", opts.platform, err)
|
||||
}
|
||||
pushOpts.Platform = &p
|
||||
}
|
||||
|
||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err = clusterClient.PushImage(ctx, opts.image, pushOpts); err != nil {
|
||||
return fmt.Errorf("push image to cluster: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", opts.image))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package image
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
@@ -6,13 +6,14 @@ import (
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "service",
|
||||
Short: "Manage services in an Uncloud cluster.",
|
||||
Use: "image",
|
||||
Short: "Manage images on machines in the cluster.",
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
NewListCommand(),
|
||||
NewRmCommand(),
|
||||
NewRunCommand(),
|
||||
NewPushCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/cmd/uc/image"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewImagesCommand returns the 'image ls' command modified to work as 'images'.
|
||||
func NewImagesCommand() *cobra.Command {
|
||||
listCmd := image.NewListCommand()
|
||||
listCmd.Use = "images [IMAGE]"
|
||||
// Remove 'list' alias since this command is already an alias.
|
||||
listCmd.Aliases = nil
|
||||
listCmd.Example = strings.ReplaceAll(listCmd.Example, "uc image ls", "uc images")
|
||||
|
||||
return listCmd
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/cmd/uc/caddy"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type addOptions struct {
|
||||
name string
|
||||
noCaddy bool
|
||||
noInstall bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
version string
|
||||
wgEndpoints []string
|
||||
wgPort int
|
||||
wgMTU int
|
||||
yes bool
|
||||
}
|
||||
|
||||
func NewAddCommand() *cobra.Command {
|
||||
opts := addOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "add [USER@]HOST[:PORT]",
|
||||
Short: "Add a remote machine to a cluster.",
|
||||
Long: `Add a new machine to an existing Uncloud cluster.
|
||||
|
||||
By default, it installs Docker and the Uncloud daemon on the machine over SSH unless --no-install
|
||||
is specified, which assumes they are already installed and running.
|
||||
|
||||
Connection methods:
|
||||
[ssh://]user@host - Use system 'ssh' command with full SSH config support (default, no prefix required)
|
||||
ssh+go://user@host - Use Go's built-in SSH library`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
// Determine connection mode and strip scheme.
|
||||
destination := args[0]
|
||||
useSSHGo := strings.HasPrefix(destination, "ssh+go://")
|
||||
destination = strings.TrimPrefix(destination, "ssh+go://")
|
||||
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
||||
destination = strings.TrimPrefix(destination, "ssh://")
|
||||
|
||||
user, host, port, err := config.SSHDestination(destination).Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse remote machine: %w", err)
|
||||
}
|
||||
remoteMachine := &cli.RemoteMachine{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
UseSSHGo: useSSHGo,
|
||||
}
|
||||
|
||||
return add(cmd.Context(), uncli, remoteMachine, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
||||
"Assign a name to the machine. (default is the machine's hostname)")
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noCaddy, "no-caddy", false,
|
||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noInstall, "no-install", false,
|
||||
"Skip installation of Docker and the Uncloud daemon on the machine. "+
|
||||
"Assumes they're already installed and running.",
|
||||
)
|
||||
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(
|
||||
&opts.sshKey, "ssh-key", "i", "",
|
||||
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().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
"WireGuard endpoint address that other machines in the cluster should use to establish "+
|
||||
"WireGuard connections\n"+
|
||||
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+
|
||||
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is the value of --wg-port if omitted.\n"+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
||||
"Defaults to the auto-detected public and routable machine IPs.",
|
||||
)
|
||||
cmd.Flags().IntVar(
|
||||
&opts.wgMTU, "wg-mtu", 0,
|
||||
"MTU of the WireGuard network interface on the machine. (default auto-detects the optimal value)",
|
||||
)
|
||||
cmd.Flags().IntVar(
|
||||
&opts.wgPort, "wg-port", network.DefaultWireGuardPort,
|
||||
"UDP port WireGuard listens on for incoming connections from other machines.",
|
||||
)
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
|
||||
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if opts.wgPort < 1 || opts.wgPort > 65535 {
|
||||
return fmt.Errorf("invalid WireGuard port %d: must be between 1 and 65535", opts.wgPort)
|
||||
}
|
||||
if opts.wgMTU != 0 && (opts.wgMTU < network.MinWireGuardMTU || opts.wgMTU > 65535) {
|
||||
return fmt.Errorf("invalid WireGuard MTU %d: must be 0 (auto-detect) or between %d and 65535",
|
||||
opts.wgMTU, network.MinWireGuardMTU)
|
||||
}
|
||||
addOpts := cli.AddMachineOptions{
|
||||
MachineName: opts.name,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
SkipInstall: opts.noInstall,
|
||||
Version: opts.version,
|
||||
WireguardMTU: opts.wgMTU,
|
||||
WireguardPort: opts.wgPort,
|
||||
AutoConfirm: opts.yes,
|
||||
}
|
||||
if len(opts.wgEndpoints) > 0 {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded, uint16(opts.wgPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
|
||||
}
|
||||
addOpts.WireguardEndpoints = endpoints
|
||||
}
|
||||
|
||||
clusterClient, machineClient, err := uncli.AddMachine(ctx, addOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
defer machineClient.Close()
|
||||
|
||||
if opts.noCaddy {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service.
|
||||
err = tui.RunSpinner(ctx, "Waiting for the machine to join the cluster...", func(ctx context.Context) error {
|
||||
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for machine to join the cluster: %w", err)
|
||||
}
|
||||
fmt.Println("Machine joined the cluster.")
|
||||
|
||||
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||
// that may cause a small downtime.
|
||||
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
||||
// use the deployed image 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) {
|
||||
// Caddy service is not deployed.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inspect caddy service: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Preparing Caddy deployment...")
|
||||
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
|
||||
fmt.Println()
|
||||
fmt.Print(plan.Format())
|
||||
|
||||
summary := plan.FormatSummary()
|
||||
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
|
||||
fmt.Println(summary)
|
||||
fmt.Println()
|
||||
|
||||
if !opts.yes {
|
||||
confirmed, err := tui.Confirm("Proceed with deployment?")
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm deployment: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
return cli.Cancelled("Caddy deploy cancelled. The machine has been added to the cluster.")
|
||||
}
|
||||
}
|
||||
|
||||
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 caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package machine
|
||||
|
||||
const (
|
||||
// PublicIPNone is the value used to indicate removal of public IP
|
||||
PublicIPNone = "none"
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/cmd/uc/caddy"
|
||||
"github.com/psviderski/uncloud/cmd/uc/dns"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type initOptions struct {
|
||||
context string
|
||||
dnsEndpoint string
|
||||
name string
|
||||
network string
|
||||
noCaddy bool
|
||||
noDNS bool
|
||||
noInstall bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
version string
|
||||
wgEndpoints []string
|
||||
wgPort int
|
||||
wgMTU int
|
||||
yes bool
|
||||
}
|
||||
|
||||
func NewInitCommand() *cobra.Command {
|
||||
opts := initOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "init [schema://]USER@HOST[:PORT]",
|
||||
Short: "Initialise a new cluster with a remote machine as the first member.",
|
||||
Long: `Initialise a new cluster by setting up a remote machine as the first member.
|
||||
This command creates a new context in your Uncloud config to manage the cluster.
|
||||
|
||||
By default, it installs Docker and the Uncloud daemon on the machine over SSH unless --no-install
|
||||
is specified, which assumes they are already installed and running.
|
||||
|
||||
Connection methods:
|
||||
[ssh://]user@host - Use system 'ssh' command with full SSH config support (default, no prefix required)
|
||||
ssh+go://user@host - Use Go's built-in SSH library`,
|
||||
Example: ` # Initialise a new cluster with default settings.
|
||||
uc machine init root@<your-server-ip>
|
||||
|
||||
# Initialise with a context name 'prod' in the Uncloud config (~/.config/uncloud/config.yaml) and machine name 'vps1'.
|
||||
uc machine init root@<your-server-ip> -c prod -n vps1
|
||||
|
||||
# Initialise with a non-root user and custom SSH port and key.
|
||||
uc machine init ubuntu@<your-server-ip>:2222 -i ~/.ssh/mykey
|
||||
|
||||
# Initialise without Caddy (no reverse proxy) and without an automatically managed domain name (xxxxxx.uncld.dev).
|
||||
# You can deploy Caddy with 'uc caddy deploy' and reserve a domain with 'uc dns reserve' later.
|
||||
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
|
||||
// TODO: support initialising a cluster on the local machine.
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
var remoteMachine *cli.RemoteMachine
|
||||
if len(args) > 0 {
|
||||
// Determine connection mode and strip scheme.
|
||||
destination := args[0]
|
||||
useSSHGo := strings.HasPrefix(destination, "ssh+go://")
|
||||
destination = strings.TrimPrefix(destination, "ssh+go://")
|
||||
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
||||
destination = strings.TrimPrefix(destination, "ssh://")
|
||||
|
||||
user, host, port, err := config.SSHDestination(destination).Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse remote machine: %w", err)
|
||||
}
|
||||
remoteMachine = &cli.RemoteMachine{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
UseSSHGo: useSSHGo,
|
||||
}
|
||||
}
|
||||
|
||||
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", cli.DefaultContextName,
|
||||
"Name of the new context to be created in the Uncloud config to manage the cluster.",
|
||||
)
|
||||
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. (default is the machine's hostname)",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.network, "network", cluster.DefaultNetwork.String(),
|
||||
"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. You can deploy it later with 'uc caddy deploy'.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noDNS, "no-dns", false,
|
||||
"Don't reserve a cluster domain in Uncloud DNS. You can reserve it later with 'uc dns reserve'.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noInstall, "no-install", false,
|
||||
"Skip installation of Docker and the Uncloud daemon on the machine. "+
|
||||
"Assumes they're already installed and running.",
|
||||
)
|
||||
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(
|
||||
&opts.sshKey, "ssh-key", "i", "",
|
||||
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().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
"WireGuard endpoint address that other machines in the cluster should use to establish "+
|
||||
"WireGuard connections\n"+
|
||||
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+
|
||||
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is the value of --wg-port if omitted.\n"+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
||||
"Defaults to the auto-detected public and routable machine IPs.",
|
||||
)
|
||||
cmd.Flags().IntVar(
|
||||
&opts.wgMTU, "wg-mtu", 0,
|
||||
"MTU of the WireGuard network interface on the machine. (default auto-detects the optimal value)",
|
||||
)
|
||||
cmd.Flags().IntVar(
|
||||
&opts.wgPort, "wg-port", network.DefaultWireGuardPort,
|
||||
"UDP port WireGuard listens on for incoming connections from other machines.",
|
||||
)
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
|
||||
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
|
||||
if uncli.Config == nil {
|
||||
// Config is nil when connecting directly to a remote machine (--connect) without using Uncloud config
|
||||
// or when being logged in on a machine and using the uncloud socket directly.
|
||||
return fmt.Errorf(
|
||||
"do not use --connect when initialising a new cluster: --connect is for overriding the connection " +
|
||||
"to an existing cluster, but 'machine init' creates a new one and writes the new cluster context " +
|
||||
"to the Uncloud config file (--uncloud-config), when logged in on a cluster machine, 'machine init' " +
|
||||
"is not supported")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if opts.wgPort < 1 || opts.wgPort > 65535 {
|
||||
return fmt.Errorf("invalid WireGuard port %d: must be between 1 and 65535", opts.wgPort)
|
||||
}
|
||||
if opts.wgMTU != 0 && (opts.wgMTU < network.MinWireGuardMTU || opts.wgMTU > 65535) {
|
||||
return fmt.Errorf("invalid WireGuard MTU %d: must be 0 (auto-detect) or between %d and 65535",
|
||||
opts.wgMTU, network.MinWireGuardMTU)
|
||||
}
|
||||
initOpts := cli.InitClusterOptions{
|
||||
Context: opts.context,
|
||||
MachineName: opts.name,
|
||||
Network: netPrefix,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
SkipInstall: opts.noInstall,
|
||||
Version: opts.version,
|
||||
WireguardMTU: opts.wgMTU,
|
||||
WireguardPort: opts.wgPort,
|
||||
AutoConfirm: opts.yes,
|
||||
}
|
||||
if len(opts.wgEndpoints) > 0 {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded, uint16(opts.wgPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
|
||||
}
|
||||
initOpts.WireguardEndpoints = endpoints
|
||||
}
|
||||
|
||||
client, err := uncli.InitCluster(ctx, initOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Since the cluster API needs a few moments to become ready after cluster initialisation,
|
||||
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
|
||||
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
|
||||
err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error {
|
||||
return client.WaitClusterReady(ctx, 1*time.Minute)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for cluster to be ready: %w", err)
|
||||
}
|
||||
fmt.Println("Cluster is ready.")
|
||||
|
||||
if opts.noCaddy && opts.noDNS {
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewLogsCommand() *cobra.Command {
|
||||
var options logs.Options
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs [SERVICE...]",
|
||||
Aliases: []string{"log"},
|
||||
Short: "View system service logs.",
|
||||
Long: `View logs from the specified system service(s) across all machines in the cluster.
|
||||
Use -m to restrict to specific machines.
|
||||
|
||||
Supported services:
|
||||
corrosion the Corrosion distributed state store
|
||||
docker the Docker daemon
|
||||
uncloud the Uncloud daemon
|
||||
|
||||
If no services are specified, streams logs from the uncloud service.`,
|
||||
Example: ` # View recent logs for the uncloud service.
|
||||
uc machine logs
|
||||
uc machine logs uncloud
|
||||
|
||||
# Stream logs in real-time (follow mode).
|
||||
uc machine logs -f uncloud
|
||||
|
||||
# View logs from multiple services.
|
||||
uc machine logs uncloud docker corrosion
|
||||
|
||||
# Show last 20 lines per machine (default is 100).
|
||||
uc machine logs -n 20 docker
|
||||
|
||||
# Show all logs without line limit.
|
||||
uc machine logs -n all docker
|
||||
|
||||
# View logs from a specific time range.
|
||||
uc machine logs --since 3h --until 1h30m docker
|
||||
|
||||
# View logs only from specific machines.
|
||||
uc machine logs -m machine1,machine2 uncloud corrosion`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return runLogs(cmd.Context(), uncli, args, options)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().AddFlagSet(logs.Flags(&options))
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error {
|
||||
if len(services) == 0 {
|
||||
services = []string{api.SystemServiceUncloud}
|
||||
}
|
||||
for _, service := range services {
|
||||
if !slices.Contains(api.SystemServices, service) {
|
||||
return fmt.Errorf("invalid system service '%s'; valid services: %s",
|
||||
service, strings.Join(api.SystemServices, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
tail, err := logs.Tail(opts.Tail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
logsOpts := api.ServiceLogsOptions{
|
||||
Follow: opts.Follow,
|
||||
Tail: tail,
|
||||
Since: opts.Since,
|
||||
Until: opts.Until,
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
||||
}
|
||||
|
||||
// Resolve machine records for the formatter's column width computation.
|
||||
machines, err := c.ListMachines(ctx, &api.MachineFilter{
|
||||
NamesOrIDs: logsOpts.Machines,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
machineNames := make([]string, 0, len(machines))
|
||||
for _, m := range machines {
|
||||
machineNames = append(machineNames, m.Machine.Name)
|
||||
}
|
||||
|
||||
// Collect one log stream per service. MachineLogs merges across machines internally.
|
||||
serviceStreams := make([]<-chan api.ServiceLogEntry, 0, len(services))
|
||||
for _, service := range services {
|
||||
ch, err := c.MachineLogs(ctx, service, logsOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream logs for system service '%s': %w", service, err)
|
||||
}
|
||||
serviceStreams = append(serviceStreams, ch)
|
||||
}
|
||||
|
||||
var stream <-chan api.ServiceLogEntry
|
||||
if len(serviceStreams) == 1 {
|
||||
stream = serviceStreams[0]
|
||||
} else {
|
||||
// Each MachineLogs stream already runs its own inner merger with stall detection,
|
||||
// so the outer merger across services skips it to avoid duplicate warnings.
|
||||
merger := client.NewLogMerger(serviceStreams, client.LogMergerOptions{})
|
||||
stream = merger.Stream()
|
||||
}
|
||||
|
||||
formatter := logs.NewFormatter(machineNames, services, opts.UTC)
|
||||
|
||||
// Print merged logs.
|
||||
for entry := range stream {
|
||||
formatter.PrintEntry(entry)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
var output 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, output)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&output, "output", "o", "",
|
||||
"Output format: 'json' or empty for a human-readable table.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, output string) error {
|
||||
if output != "" && output != "json" {
|
||||
return fmt.Errorf("unsupported output format '%s' (supported: json)", output)
|
||||
}
|
||||
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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)
|
||||
}
|
||||
|
||||
if output == "json" {
|
||||
data, err := json.MarshalIndent(machines.ToNative(), "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal machines: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print the list of machines in a table format.
|
||||
t := tui.NewTable()
|
||||
t.Headers("NAME", "STATE", "ADDRESS", "PUBLIC IP", "WIREGUARD ENDPOINTS",
|
||||
"OS", "KERNEL", "ARCH", "DOCKER", "VERSION")
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
arch := "-"
|
||||
if m.Arch != "" {
|
||||
arch = m.Arch
|
||||
}
|
||||
|
||||
osName := "-"
|
||||
if m.OsPrettyName != "" {
|
||||
osName = m.OsPrettyName
|
||||
}
|
||||
|
||||
kernel := "-"
|
||||
if m.KernelVersion != "" {
|
||||
kernel = m.KernelVersion
|
||||
}
|
||||
|
||||
daemonVersion := "-"
|
||||
if m.DaemonVersion != "" {
|
||||
daemonVersion = m.DaemonVersion
|
||||
}
|
||||
|
||||
dockerVersion := "-"
|
||||
if m.DockerVersion != "" {
|
||||
dockerVersion = m.DockerVersion
|
||||
}
|
||||
|
||||
t.Row(
|
||||
m.Name,
|
||||
capitalise(member.State.String()),
|
||||
subnet.String(),
|
||||
publicIP,
|
||||
strings.Join(endpoints, tui.Faint.Render(", ")),
|
||||
osName,
|
||||
kernel,
|
||||
arch,
|
||||
dockerVersion,
|
||||
daemonVersion,
|
||||
)
|
||||
}
|
||||
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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,42 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRenameCommand() *cobra.Command {
|
||||
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, args[0], args[1])
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func rename(ctx context.Context, uncli *cli.CLI, oldName, newName string) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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,273 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/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/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"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
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Machines(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Verify the machine exists in the cluster.
|
||||
member, err := client.InspectMachine(ctx, nameOrID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect machine '%s': %w", nameOrID, err)
|
||||
}
|
||||
m := member.Machine
|
||||
|
||||
// Create a proxy context for the machine being removed.
|
||||
// This is used for calls that need to run directly on that machine.
|
||||
rmCtx := client.ProxySingleMachineContext(ctx, m.Id)
|
||||
|
||||
// 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 fmt.Errorf("cannot remove the machine you are currently connected to. "+
|
||||
"Please connect to another machine in the cluster and try again. "+
|
||||
"Change the default connection for the current cluster using 'uc ctx conn' or "+
|
||||
"manually update 'connections' in your Uncloud config (%s). "+
|
||||
"For more information on connecting to a cluster, see "+
|
||||
"https://uncloud.run/docs/concepts/clusters/connecting/", uncli.Config.Path())
|
||||
// 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(rmCtx, "", 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)
|
||||
lipgloss.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 := tui.Confirm("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm removal: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
return cli.Cancelled("Cancelled. Machine was not removed.")
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// Initiate reset before removing the machine from the cluster to stop it from updating the cluster store.
|
||||
// This is still optimistic as Reset only triggers the reset process that runs asynchronously.
|
||||
if reset && reachable {
|
||||
_, err = client.MachineClient.Reset(rmCtx, &pb.ResetRequest{})
|
||||
if err != nil {
|
||||
tui.PrintWarning(fmt.Sprintf("Failed to reset machine: %v\n", err))
|
||||
} else {
|
||||
fmt.Println("Machine reset initiated and will complete in the background.")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Remove the connection to the machine from the uncloud config if it exists.
|
||||
if uncli.Config != nil {
|
||||
contextName := uncli.ContextOverrideOrCurrent()
|
||||
if context, ok := uncli.Config.Contexts[contextName]; ok {
|
||||
for i, c := range context.Connections {
|
||||
if c.MachineID == m.Id {
|
||||
context.Connections = slices.Delete(context.Connections, i, i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := uncli.Config.Save(); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, tui.FormatImage(ctr.Config.Image, tui.NoStyle), 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,14 +6,19 @@ import (
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "machine",
|
||||
Short: "Manage machines in an Uncloud cluster.",
|
||||
Use: "machine",
|
||||
Aliases: []string{"m"},
|
||||
Short: "Manage machines in the cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewAddCommand(),
|
||||
NewInitCommand(),
|
||||
NewListCommand(),
|
||||
NewTokenCommand(),
|
||||
NewLogsCommand(),
|
||||
NewRenameCommand(),
|
||||
NewRmCommand(),
|
||||
NewRTTCommand(),
|
||||
NewUpdateCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
func NewRTTCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rtt",
|
||||
Short: "Show round-trip times between machines.",
|
||||
Long: `Show round-trip times between machines.
|
||||
|
||||
Round-trip time statistics are collected from the Corrosion gossip protocol
|
||||
and represent the median of recent RTT samples between each pair of machines
|
||||
in the cluster. The values shown include the median RTT and standard deviation
|
||||
for each machine-to-machine connection.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return rtt(cmd.Context(), uncli)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func rtt(ctx context.Context, uncli *cli.CLI) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Setup context to proxy request to all machines.
|
||||
ctx = client.ProxyMachinesContext(ctx, nil)
|
||||
|
||||
resp, err := client.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect machines: %w", err)
|
||||
}
|
||||
|
||||
// Map machine IDs to names for display from the response.
|
||||
machineNames := make(map[string]string)
|
||||
for _, m := range resp.Machines {
|
||||
// NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed.
|
||||
if m.Metadata == nil {
|
||||
tui.PrintWarning("metadata is missing in response from unknown server")
|
||||
continue
|
||||
}
|
||||
if m.Metadata.Error != "" {
|
||||
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, m.Metadata.Error))
|
||||
continue
|
||||
}
|
||||
if m.Machine == nil {
|
||||
continue
|
||||
}
|
||||
machineNames[m.Machine.Id] = m.Machine.Name
|
||||
}
|
||||
|
||||
type row struct {
|
||||
machine string
|
||||
peer string
|
||||
median time.Duration
|
||||
stdDev time.Duration
|
||||
}
|
||||
var rows []row
|
||||
|
||||
for _, m := range resp.Machines {
|
||||
// Unlikely to occur, but might be a possible edge case when
|
||||
// a machine is still initializing. So just to be safe.
|
||||
if m.Machine == nil || m.Rtts == nil {
|
||||
continue
|
||||
}
|
||||
for peerID, stats := range m.Rtts {
|
||||
peerName := peerID
|
||||
if name, ok := machineNames[peerID]; ok {
|
||||
peerName = name
|
||||
}
|
||||
rows = append(rows, row{
|
||||
machine: m.Machine.Name,
|
||||
peer: peerName,
|
||||
median: stats.Median.AsDuration(),
|
||||
stdDev: stats.StdDev.AsDuration(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by machine name then peer name.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].machine == rows[j].machine {
|
||||
return rows[i].peer < rows[j].peer
|
||||
}
|
||||
return rows[i].machine < rows[j].machine
|
||||
})
|
||||
|
||||
// Print table.
|
||||
t := tui.NewTable()
|
||||
t.Headers("MACHINE", "PEER", "MEDIAN", "STDDEV")
|
||||
|
||||
for _, r := range rows {
|
||||
t.Row(r.machine, r.peer, tui.FormatRTT(r.median), formatRTTStdDev(r.stdDev))
|
||||
}
|
||||
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatRTTStdDev formats a round-trip time standard deviation with one decimal place, e.g. "±19.4ms".
|
||||
func formatRTTStdDev(d time.Duration) string {
|
||||
return fmt.Sprintf("±%.1fms", float64(d)/float64(time.Millisecond))
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type updateOptions struct {
|
||||
name string
|
||||
publicIP string
|
||||
wgEndpoints []string
|
||||
}
|
||||
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
opts := updateOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "update MACHINE [flags]",
|
||||
Short: "Update machine configuration in the cluster.",
|
||||
Long: `Update machine configuration in the cluster.
|
||||
|
||||
Change the name, public IP address, or WireGuard endpoints of an existing machine.
|
||||
At least one flag must be specified to perform an update.`,
|
||||
Example: ` # Rename a machine.
|
||||
uc machine update machine1 --name web-server
|
||||
|
||||
# Set the public IP address of a machine.
|
||||
uc machine update machine1 --public-ip 203.0.113.10
|
||||
|
||||
# Remove the public IP address from a machine.
|
||||
uc machine update machine1 --public-ip none
|
||||
|
||||
# Update WireGuard endpoints for a machine.
|
||||
uc machine update machine1 --wg-endpoint 203.0.113.10 --wg-endpoint 192.168.1.5
|
||||
|
||||
# Update multiple properties at once.
|
||||
uc machine update machine1 --name web-server --public-ip 203.0.113.10`,
|
||||
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])
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Machines(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
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().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
fmt.Sprintf("WireGuard endpoint address that other machines in the cluster should use to establish "+
|
||||
"WireGuard connections\n"+
|
||||
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+
|
||||
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is %d if omitted.\n",
|
||||
network.DefaultWireGuardPort)+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.",
|
||||
)
|
||||
|
||||
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") && !cmd.Flags().Changed("wg-endpoint") {
|
||||
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip, --wg-endpoint)")
|
||||
}
|
||||
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Resolve the machine to capture its current configuration for the before/after report and to validate existence.
|
||||
machine, err := client.InspectMachine(ctx, machineNameOrID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find machine: %w", err)
|
||||
}
|
||||
|
||||
req := &pb.UpdateMachineRequest{}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and set endpoints if the flag was explicitly provided.
|
||||
if cmd.Flags().Changed("wg-endpoint") {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded, network.DefaultWireGuardPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(endpoints) == 0 {
|
||||
return fmt.Errorf("at least one endpoint must be specified if --wg-endpoint flag is used")
|
||||
}
|
||||
req.Endpoints = endpoints
|
||||
}
|
||||
|
||||
updatedMachine, err := client.UpdateMachine(ctx, machine.Machine.Id, 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))
|
||||
}
|
||||
if cmd.Flags().Changed("wg-endpoint") {
|
||||
formatEndpoints := func(eps []*pb.IPPort) string {
|
||||
if len(eps) == 0 {
|
||||
return "none"
|
||||
}
|
||||
parts := make([]string, len(eps))
|
||||
for i, ep := range eps {
|
||||
ap, _ := ep.ToAddrPort()
|
||||
parts[i] = ap.String()
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
oldEndpoints := formatEndpoints(machine.Machine.Network.Endpoints)
|
||||
newEndpoints := formatEndpoints(updatedMachine.Network.Endpoints)
|
||||
changes = append(changes, fmt.Sprintf("endpoints: %s -> %s", oldEndpoints, newEndpoints))
|
||||
}
|
||||
|
||||
fmt.Printf("Machine '%s' (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id)
|
||||
for _, change := range changes {
|
||||
fmt.Printf(" %s\n", change)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/cmd/uc/caddy"
|
||||
cmdcontext "github.com/psviderski/uncloud/cmd/uc/context"
|
||||
"github.com/psviderski/uncloud/cmd/uc/dns"
|
||||
"github.com/psviderski/uncloud/cmd/uc/image"
|
||||
cmdmachine "github.com/psviderski/uncloud/cmd/uc/machine"
|
||||
"github.com/psviderski/uncloud/cmd/uc/service"
|
||||
"github.com/psviderski/uncloud/cmd/uc/volume"
|
||||
"github.com/psviderski/uncloud/cmd/uc/wg"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/log"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type globalOptions struct {
|
||||
configPath string
|
||||
connect string
|
||||
context string
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.InitLoggerFromEnv()
|
||||
|
||||
opts := globalOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "uc",
|
||||
Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
|
||||
cli.BindEnvToFlag(cmd, "context", "UNCLOUD_CONTEXT")
|
||||
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
|
||||
|
||||
var conn *config.MachineConnection
|
||||
if opts.connect != "" {
|
||||
if after, ok := strings.CutPrefix(opts.connect, "tcp://"); ok {
|
||||
addrPort, err := netip.ParseAddrPort(after)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse TCP address: %w", err)
|
||||
}
|
||||
conn = &config.MachineConnection{
|
||||
TCP: &addrPort,
|
||||
}
|
||||
} else if after, ok := strings.CutPrefix(opts.connect, "ssh+go://"); ok {
|
||||
conn = &config.MachineConnection{
|
||||
SSHGo: config.SSHDestination(after),
|
||||
}
|
||||
} else if after, ok := strings.CutPrefix(opts.connect, "ssh+cli://"); ok {
|
||||
// Backward-compatible alias for ssh://.
|
||||
conn = &config.MachineConnection{
|
||||
SSH: config.SSHDestination(after),
|
||||
}
|
||||
} else if strings.HasPrefix(opts.connect, "unix://") {
|
||||
conn = &config.MachineConnection{
|
||||
Unix: opts.connect[len("unix://"):],
|
||||
}
|
||||
} else {
|
||||
// Default: system ssh CLI command (no prefix or ssh:// prefix).
|
||||
dest := strings.TrimPrefix(opts.connect, "ssh://")
|
||||
conn = &config.MachineConnection{
|
||||
SSH: config.SSHDestination(dest),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configPath := fs.ExpandHomeDir(opts.configPath)
|
||||
|
||||
if opts.connect == "" {
|
||||
if !fs.Exists(configPath) && fs.Exists(machine.DefaultUncloudSockPath) {
|
||||
conn = &config.MachineConnection{
|
||||
Unix: machine.DefaultUncloudSockPath,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uncli, err := cli.New(configPath, conn, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialise CLI: %w", err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), "cli", uncli))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
||||
"Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock")
|
||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||
cmd.PersistentFlags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]")
|
||||
|
||||
// Set custom help function to show links to docs and Discord only for the root 'uc' command.
|
||||
defaultHelpFunc := cmd.HelpFunc()
|
||||
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
|
||||
defaultHelpFunc(c, args)
|
||||
// Only show links for the root 'uc' command.
|
||||
if c.Name() == "uc" {
|
||||
fmt.Fprintln(c.OutOrStdout())
|
||||
fmt.Fprintf(c.OutOrStdout(), "Learn more about Uncloud: %s\n",
|
||||
tui.URLStyle.Render(version.DocsURL))
|
||||
fmt.Fprintf(c.OutOrStdout(), "Join our Discord community: %s\n",
|
||||
tui.URLStyle.Render(version.DiscordURL))
|
||||
}
|
||||
})
|
||||
|
||||
cmd.AddGroup(&cobra.Group{
|
||||
ID: "service",
|
||||
Title: "Deploy and manage services:",
|
||||
})
|
||||
|
||||
cmd.AddCommand(
|
||||
NewBuildCommand(),
|
||||
NewDeployCommand(),
|
||||
NewDocsCommand(),
|
||||
NewImagesCommand(),
|
||||
NewPsCommand(),
|
||||
NewProxyCommand(),
|
||||
caddy.NewRootCommand(),
|
||||
cmdcontext.NewRootCommand(),
|
||||
dns.NewRootCommand(),
|
||||
image.NewRootCommand(),
|
||||
cmdmachine.NewRootCommand(),
|
||||
service.NewRootCommand(),
|
||||
service.NewExecCommand("service"),
|
||||
service.NewInspectCommand("service"),
|
||||
service.NewListCommand("service"),
|
||||
service.NewLogsCommand("service"),
|
||||
service.NewRmCommand("service"),
|
||||
service.NewRunCommand("service"),
|
||||
service.NewScaleCommand("service"),
|
||||
service.NewStartCommand("service"),
|
||||
service.NewStopCommand("service"),
|
||||
NewVersionCommand(),
|
||||
volume.NewRootCommand(),
|
||||
wg.NewRootCommand(),
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
if cancelled, ok := errors.AsType[*cli.CancelledError](err); ok {
|
||||
fmt.Fprintln(os.Stderr, cancelled.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
cobra.CheckErr(err)
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/proxy"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type proxyOptions struct {
|
||||
localPort int
|
||||
remotePort int
|
||||
service string
|
||||
}
|
||||
|
||||
// NewProxyCommand creates a new command to proxy a local port to a service's port in the cluster.
|
||||
func NewProxyCommand() *cobra.Command {
|
||||
opts := proxyOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "proxy SERVICE [LOCAL_PORT:]REMOTE_PORT",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Short: "Proxy a service port to a local port.",
|
||||
Long: `Proxy a service port in the cluster to a local port on this machine.
|
||||
|
||||
If the service runs multiple containers, the command connects to the first running and healthy one.
|
||||
If you don't provide a local port, the command picks a random one.
|
||||
|
||||
The connection stays open for as long as the command runs.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
opts.service = args[0]
|
||||
|
||||
parts := strings.Split(args[1], ":")
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
remoteport, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid remote port: '%s': %w", parts[0], err)
|
||||
}
|
||||
opts.remotePort = remoteport
|
||||
case 2:
|
||||
localport, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid local port: '%s': %w", parts[0], err)
|
||||
}
|
||||
remoteport, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid remote port: '%s': %w", parts[1], err)
|
||||
}
|
||||
opts.localPort = localport
|
||||
opts.remotePort = remoteport
|
||||
default:
|
||||
return fmt.Errorf("invalid port")
|
||||
}
|
||||
|
||||
return runProxy(cmd.Context(), uncli, opts)
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Services(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runProxy(ctx context.Context, uncli *cli.CLI, opts proxyOptions) error {
|
||||
if opts.localPort < 0 || opts.localPort > 65535 {
|
||||
return fmt.Errorf("invalid local port %d: must be between 0 and 65535", opts.localPort)
|
||||
}
|
||||
if opts.remotePort < 1 || opts.remotePort > 65535 {
|
||||
return fmt.Errorf("invalid remote port %d: must be between 1 and 65535", opts.remotePort)
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
svc, err := clusterClient.InspectService(ctx, opts.service)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNotFound) {
|
||||
return fmt.Errorf("service '%s' not found in the cluster", opts.service)
|
||||
}
|
||||
return fmt.Errorf("inspect service '%s': %w", opts.service, err)
|
||||
}
|
||||
|
||||
// Pick the first running and healthy container to proxy to.
|
||||
var ctr *api.MachineServiceContainer
|
||||
for i := range svc.Containers {
|
||||
if svc.Containers[i].Container.Healthy() {
|
||||
ctr = &svc.Containers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if ctr == nil {
|
||||
return fmt.Errorf("no running healthy container found for service '%s'", opts.service)
|
||||
}
|
||||
|
||||
containerID := ctr.Container.ShortID()
|
||||
ip := ctr.Container.UncloudNetworkIP()
|
||||
if !ip.IsValid() {
|
||||
return fmt.Errorf("container '%s' is not connected to the uncloud Docker network (could be host network)",
|
||||
containerID)
|
||||
}
|
||||
|
||||
dialer, err := clusterClient.Dialer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get proxy dialer: %w", err)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(opts.localPort)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on 127.0.0.1:%d: %w", opts.localPort, err)
|
||||
}
|
||||
|
||||
// There is no precheck if we can connect, as this always succeeds, only the proxy connects with the
|
||||
// endpoint and shuffles the data, *it* will actually experience errors.
|
||||
remoteAddr := net.JoinHostPort(ip.String(), strconv.Itoa(opts.remotePort))
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
p := &proxy.Proxy{
|
||||
Listener: listener,
|
||||
RemoteAddr: remoteAddr,
|
||||
DialContext: dialer.DialContext,
|
||||
OnError: func(err error) {
|
||||
fmt.Printf("Failed to proxy to '%s': %v\n", remoteAddr, err)
|
||||
cancel()
|
||||
},
|
||||
}
|
||||
|
||||
// Run the proxy in the background and signal when it has fully shut down.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
p.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Prefix the local address with the scheme for common HTTP ports so it becomes control-clickable in most
|
||||
// terminals. We assume plain HTTP since TLS is typically terminated by Caddy in front of the service.
|
||||
fmt.Printf("%s%s → %s (%s%s%s)\n", schemeForPort(opts.remotePort), p.Listener.Addr().String(),
|
||||
remoteAddr, opts.service, tui.Faint.Render("/"), containerID)
|
||||
|
||||
<-ctx.Done()
|
||||
// Wait for the proxy to drain in-flight connections and shut down gracefully.
|
||||
<-done
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// schemeForPort returns the "http://" URL scheme prefix for the ports most likely to serve plain HTTP,
|
||||
// or an empty string otherwise.
|
||||
func schemeForPort(port int) string {
|
||||
switch port {
|
||||
case 80, 3000, 8000, 8080, 8081, 8888, 9090:
|
||||
return "http://"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
sortByService = "service"
|
||||
sortByMachine = "machine"
|
||||
sortByHealth = "health"
|
||||
)
|
||||
|
||||
type containerHighlight int
|
||||
|
||||
const (
|
||||
highlightDanger containerHighlight = iota
|
||||
highlightWarning
|
||||
highlightSuccess
|
||||
highlightNormal
|
||||
)
|
||||
|
||||
type psOptions struct {
|
||||
sortBy string
|
||||
}
|
||||
|
||||
func NewPsCommand() *cobra.Command {
|
||||
opts := psOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "ps",
|
||||
Short: "List all service containers.",
|
||||
Long: `List all service containers across all machines in the cluster.
|
||||
|
||||
This command provides a comprehensive overview of all running containers that are part of a service,
|
||||
making it easy to see the distribution and status of containers across the cluster.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
if opts.sortBy != sortByService && opts.sortBy != sortByMachine && opts.sortBy != sortByHealth {
|
||||
return fmt.Errorf("invalid value for --sort: %q, must be one of '%s', '%s' or '%s'", opts.sortBy,
|
||||
sortByService, sortByMachine, sortByHealth)
|
||||
}
|
||||
|
||||
return runPs(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: "service",
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.sortBy, "sort", "s", sortByService,
|
||||
"Sort containers by 'service', 'machine', or 'health'.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type containerInfo struct {
|
||||
serviceName string
|
||||
machineName string
|
||||
id string
|
||||
image string
|
||||
status string
|
||||
highlight containerHighlight
|
||||
created time.Time
|
||||
ip string
|
||||
// Hook type (e.g., "pre-deploy"), empty for regular containers.
|
||||
hook string
|
||||
}
|
||||
|
||||
func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
var containers []containerInfo
|
||||
err = tui.RunSpinner(ctx, "Collecting container info...", func(ctx context.Context) error {
|
||||
containers, err = collectContainers(ctx, clusterClient)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("collect containers: %w", err)
|
||||
}
|
||||
|
||||
// Sort the containers based on the sorting option.
|
||||
sort.SliceStable(containers, func(i, j int) bool {
|
||||
a, b := containers[i], containers[j]
|
||||
switch opts.sortBy {
|
||||
case sortByHealth:
|
||||
if a.highlight != b.highlight {
|
||||
return a.highlight < b.highlight
|
||||
}
|
||||
if a.serviceName != b.serviceName {
|
||||
return a.serviceName < b.serviceName
|
||||
}
|
||||
case sortByMachine:
|
||||
if a.machineName != b.machineName {
|
||||
return a.machineName < b.machineName
|
||||
}
|
||||
if a.serviceName != b.serviceName {
|
||||
return a.serviceName < b.serviceName
|
||||
}
|
||||
default: // sortByService
|
||||
if a.serviceName != b.serviceName {
|
||||
return a.serviceName < b.serviceName
|
||||
}
|
||||
}
|
||||
// Fallback to creation time (newest first).
|
||||
return a.created.After(b.created)
|
||||
})
|
||||
|
||||
return printContainers(containers)
|
||||
}
|
||||
|
||||
func printContainers(containers []containerInfo) error {
|
||||
t := tui.NewTable()
|
||||
|
||||
// Show HOOK column only when hook containers are present.
|
||||
hasHooks := false
|
||||
for _, ctr := range containers {
|
||||
if ctr.hook != "" {
|
||||
hasHooks = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasHooks {
|
||||
t.Headers("SERVICE", "CONTAINER ID", "IMAGE", "CREATED", "STATUS", "HOOK", "IP ADDRESS", "MACHINE")
|
||||
} else {
|
||||
t.Headers("SERVICE", "CONTAINER ID", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
|
||||
}
|
||||
|
||||
for _, ctr := range containers {
|
||||
id := ctr.id
|
||||
if len(id) > 12 {
|
||||
id = id[:12]
|
||||
}
|
||||
|
||||
created := units.HumanDuration(time.Now().UTC().Sub(ctr.created)) + " ago"
|
||||
|
||||
var statusStyle lipgloss.Style
|
||||
switch ctr.highlight {
|
||||
case highlightSuccess:
|
||||
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green
|
||||
case highlightDanger:
|
||||
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Red
|
||||
case highlightWarning:
|
||||
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // Yellow
|
||||
default:
|
||||
statusStyle = lipgloss.NewStyle() // Default
|
||||
}
|
||||
|
||||
if hasHooks {
|
||||
t.Row(
|
||||
ctr.serviceName,
|
||||
id,
|
||||
tui.FormatImage(ctr.image, tui.NoStyle),
|
||||
created,
|
||||
statusStyle.Render(ctr.status),
|
||||
ctr.hook,
|
||||
ctr.ip,
|
||||
ctr.machineName,
|
||||
)
|
||||
} else {
|
||||
t.Row(
|
||||
ctr.serviceName,
|
||||
id,
|
||||
tui.FormatImage(ctr.image, tui.NoStyle),
|
||||
created,
|
||||
statusStyle.Render(ctr.status),
|
||||
ctr.ip,
|
||||
ctr.machineName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo, error) {
|
||||
listCtx := cli.ProxyMachinesContext(ctx, nil)
|
||||
|
||||
// List all service containers across all machines in the cluster.
|
||||
machineContainers, err := cli.Docker.ListServiceContainers(
|
||||
listCtx, "", container.ListOptions{All: true},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list service containers: %w", err)
|
||||
}
|
||||
|
||||
var containers []containerInfo
|
||||
for _, msc := range machineContainers {
|
||||
// NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed.
|
||||
if msc.Metadata == nil {
|
||||
tui.PrintWarning("metadata is missing in response from unknown server")
|
||||
continue
|
||||
}
|
||||
|
||||
machineName := msc.Metadata.MachineName
|
||||
|
||||
if msc.Metadata.Error != "" {
|
||||
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s",
|
||||
machineName, msc.Metadata.Error))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ctr := range append(msc.Containers, msc.HookContainers...) {
|
||||
if ctr.Container.State == nil || ctr.Container.Config == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
status, err := ctr.Container.HumanState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get human state for container %s: %w", ctr.Container.ID, err)
|
||||
}
|
||||
|
||||
var highlight containerHighlight
|
||||
healthStatus := ""
|
||||
if ctr.Container.State.Health != nil {
|
||||
healthStatus = ctr.Container.State.Health.Status
|
||||
}
|
||||
|
||||
if healthStatus == container.Unhealthy || ctr.Container.State.Status == "dead" || ctr.Container.State.OOMKilled || ctr.Container.State.Dead {
|
||||
highlight = highlightDanger
|
||||
} else if healthStatus == container.Healthy {
|
||||
highlight = highlightSuccess
|
||||
} else if ctr.Container.State.Status == "running" {
|
||||
highlight = highlightNormal
|
||||
} else if ctr.IsHook() && ctr.Container.State.Status == "exited" && ctr.Container.State.ExitCode == 0 {
|
||||
// Hook containers (e.g., pre-deploy) are expected to exit successfully.
|
||||
highlight = highlightNormal
|
||||
} else { // Other non-critical but noteworthy states
|
||||
highlight = highlightWarning
|
||||
}
|
||||
|
||||
created, _ := time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
||||
|
||||
ip := ctr.Container.UncloudNetworkIP()
|
||||
ipStr := ""
|
||||
// The container might not have an IP if it's not running or uses the host network.
|
||||
if ip.IsValid() {
|
||||
ipStr = ip.String()
|
||||
}
|
||||
|
||||
info := containerInfo{
|
||||
serviceName: ctr.ServiceName(),
|
||||
machineName: machineName,
|
||||
id: ctr.Container.ID,
|
||||
image: ctr.Container.Config.Image,
|
||||
status: status,
|
||||
highlight: highlight,
|
||||
created: created,
|
||||
ip: ipStr,
|
||||
hook: ctr.Config.Labels[api.LabelHook],
|
||||
}
|
||||
containers = append(containers, info)
|
||||
}
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type mockDockerClient struct {
|
||||
pb.DockerClient
|
||||
listResp *pb.ListServiceContainersResponse
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ListServiceContainers(ctx context.Context, in *pb.ListServiceContainersRequest, opts ...grpc.CallOption) (*pb.ListServiceContainersResponse, error) {
|
||||
return m.listResp, m.listErr
|
||||
}
|
||||
|
||||
func TestCollectContainers(t *testing.T) {
|
||||
containerData1 := map[string]interface{}{
|
||||
"Id": "container1",
|
||||
"Name": "container-1",
|
||||
"Config": map[string]any{
|
||||
"Image": "image-1",
|
||||
},
|
||||
"State": map[string]any{
|
||||
"Status": "running",
|
||||
"StartedAt": "2023-01-01T12:00:00Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||
},
|
||||
"NetworkSettings": map[string]any{
|
||||
"Networks": map[string]any{},
|
||||
},
|
||||
}
|
||||
containerJSON1, _ := json.Marshal(containerData1)
|
||||
|
||||
containerData2 := map[string]any{
|
||||
"Id": "container2",
|
||||
"Name": "container-2",
|
||||
"Config": map[string]any{
|
||||
"Image": "image-2",
|
||||
},
|
||||
"State": map[string]any{
|
||||
"Status": "running",
|
||||
"StartedAt": "2023-01-01T12:00:00Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||
},
|
||||
"NetworkSettings": map[string]any{
|
||||
"Networks": map[string]any{},
|
||||
},
|
||||
}
|
||||
containerJSON2, _ := json.Marshal(containerData2)
|
||||
|
||||
serviceSpecJSON, _ := json.Marshal(map[string]any{})
|
||||
|
||||
mockDocker := &mockDockerClient{
|
||||
listResp: &pb.ListServiceContainersResponse{
|
||||
Messages: []*pb.MachineServiceContainers{
|
||||
{
|
||||
Metadata: &pb.Metadata{MachineAddr: "10.0.0.1", MachineName: "machine-1"},
|
||||
Containers: []*pb.ServiceContainer{
|
||||
{
|
||||
Container: containerJSON1,
|
||||
ServiceSpec: serviceSpecJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Metadata: &pb.Metadata{MachineAddr: "10.0.0.2", MachineName: "machine-2"},
|
||||
Containers: []*pb.ServiceContainer{
|
||||
{
|
||||
Container: containerJSON2,
|
||||
ServiceSpec: serviceSpecJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cli := &client.Client{
|
||||
Docker: &docker.Client{GRPCClient: mockDocker},
|
||||
}
|
||||
|
||||
containers, err := collectContainers(context.Background(), cli)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, containers, 2)
|
||||
for _, c := range containers {
|
||||
if c.id == "container1" {
|
||||
assert.Equal(t, "machine-1", c.machineName)
|
||||
} else if c.id == "container2" {
|
||||
assert.Equal(t, "machine-2", c.machineName)
|
||||
} else {
|
||||
t.Errorf("unexpected container id: %s", c.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type execCliOptions struct {
|
||||
detach bool
|
||||
interactive bool
|
||||
noTty bool
|
||||
containerId string
|
||||
}
|
||||
|
||||
var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
|
||||
|
||||
func NewExecCommand(groupID string) *cobra.Command {
|
||||
opts := execCliOptions{}
|
||||
|
||||
execCmd := &cobra.Command{
|
||||
Use: "exec [OPTIONS] SERVICE [COMMAND ARGS...]",
|
||||
Short: "Execute a command in a running service container.",
|
||||
Long: `Execute a command (interactive shell by default) in a running container within a service.
|
||||
If the service has multiple replicas and no container ID is specified, the command will be executed in a random container.
|
||||
`,
|
||||
Example: `
|
||||
# Start an interactive shell ("bash" or "sh" will be tried by default)
|
||||
uc exec web-service
|
||||
|
||||
# Start an interactive shell with explicit command
|
||||
uc exec web-service /bin/zsh
|
||||
|
||||
# List files in the specific container of the service; --container accepts full ID or a (unique) prefix
|
||||
uc exec --container d792e web-service ls -la
|
||||
|
||||
# Pipe input to a command inside the service container
|
||||
cat backup.sql | uc exec -T db-service psql -U postgres mydb
|
||||
|
||||
# Run a task in the background (detached mode)
|
||||
uc exec -d web-service /scripts/cleanup.sh`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
serviceName, command := normalizeExecArgs(args)
|
||||
if len(command) == 0 {
|
||||
command = DEFAULT_COMMAND
|
||||
}
|
||||
return runExec(cmd.Context(), uncli, serviceName, command, opts)
|
||||
},
|
||||
GroupID: groupID,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Services(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background")
|
||||
|
||||
execCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", false,
|
||||
"Disable pseudo-TTY allocation. By default 'uc exec' allocates a TTY when connected to a terminal.")
|
||||
|
||||
// Keep "-i" and "-t" flags hidden for compatibility with docker exec
|
||||
execCmd.Flags().BoolVarP(&opts.interactive, "interactive", "i", true, "Keep STDIN open even if not attached")
|
||||
execCmd.Flags().MarkHidden("interactive")
|
||||
|
||||
execCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY")
|
||||
execCmd.Flags().MarkHidden("tty")
|
||||
|
||||
// Common flags
|
||||
execCmd.Flags().StringVar(&opts.containerId, "container", "",
|
||||
"ID of the container to exec into. Accepts full ID or a unique prefix "+
|
||||
"(default is the random container of the service)")
|
||||
|
||||
// This tells Cobra that all flags must come before positional arguments, so that
|
||||
// commands with their own flags can be handled correctly.
|
||||
execCmd.Flags().SetInterspersed(false)
|
||||
|
||||
return execCmd
|
||||
}
|
||||
|
||||
func normalizeExecArgs(args []string) (serviceName string, command []string) {
|
||||
serviceName = args[0]
|
||||
command = args[1:]
|
||||
if len(command) > 0 && command[0] == "--" {
|
||||
command = command[1:]
|
||||
}
|
||||
return serviceName, command
|
||||
}
|
||||
|
||||
func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error {
|
||||
// Disable TTY allocation if not connected to a terminal
|
||||
if !tui.IsStdoutTerminal() {
|
||||
opts.noTty = true
|
||||
}
|
||||
|
||||
if !opts.detach {
|
||||
// Check if we're trying to attach to a TTY from a non-TTY client, e.g.
|
||||
// when doing an 'cmd | uc exec ...'
|
||||
stdin := streams.NewIn(os.Stdin)
|
||||
// TODO: this logic/behavior mirrors docker-compose, but we can be smarter about it and detect TTY dynamically
|
||||
if err := stdin.CheckTty(opts.interactive, !opts.noTty); err != nil {
|
||||
return fmt.Errorf("check TTY: %w; use -T option to disable TTY allocation", err)
|
||||
}
|
||||
}
|
||||
|
||||
client, err := uncli.ConnectClusterWithOptions(ctx, cli.ConnectOptions{
|
||||
ShowProgress: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
execConfig := api.ExecOptions{
|
||||
Command: command,
|
||||
AttachStdin: opts.interactive,
|
||||
Tty: !opts.noTty,
|
||||
Detach: opts.detach,
|
||||
}
|
||||
|
||||
if !opts.detach {
|
||||
execConfig.AttachStdout = true
|
||||
execConfig.AttachStderr = true
|
||||
}
|
||||
|
||||
exitCode, err := client.ExecContainer(ctx, serviceName, opts.containerId, execConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec container: %w", err)
|
||||
}
|
||||
|
||||
// For non-detached mode, exit with the same code as the executed command
|
||||
if !opts.detach {
|
||||
if exitCode != 0 {
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalizeExecArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantServiceName string
|
||||
wantCommand []string
|
||||
}{
|
||||
{
|
||||
name: "service only",
|
||||
args: []string{"test-service"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{},
|
||||
},
|
||||
{
|
||||
name: "service with command",
|
||||
args: []string{"test-service", "echo", "hello"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{"echo", "hello"},
|
||||
},
|
||||
{
|
||||
name: "service with separator and command",
|
||||
args: []string{"test-service", "--", "echo", "hello"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{"echo", "hello"},
|
||||
},
|
||||
{
|
||||
name: "service with separator only",
|
||||
args: []string{"test-service", "--"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{},
|
||||
},
|
||||
{
|
||||
name: "separator preserves command flag",
|
||||
args: []string{"test-service", "--", "--help"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{"--help"},
|
||||
},
|
||||
{
|
||||
name: "only first separator is removed",
|
||||
args: []string{"test-service", "--", "cmd", "--", "arg"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{"cmd", "--", "arg"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotServiceName, gotCommand := normalizeExecArgs(tt.args)
|
||||
assert.Equal(t, tt.wantServiceName, gotServiceName)
|
||||
assert.Equal(t, tt.wantCommand, gotCommand)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type inspectOptions struct {
|
||||
service string
|
||||
}
|
||||
|
||||
func NewInspectCommand(groupID string) *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)
|
||||
},
|
||||
GroupID: groupID,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Services(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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)
|
||||
}
|
||||
|
||||
fmt.Printf("Service ID: %s\n", svc.ID)
|
||||
fmt.Printf("Name: %s\n", svc.Name)
|
||||
fmt.Printf("Mode: %s\n", svc.Mode)
|
||||
fmt.Println()
|
||||
|
||||
// Combine regular and hook containers.
|
||||
allContainers := append(svc.Containers, svc.HookContainers...)
|
||||
|
||||
// Parse created times for sorting and display.
|
||||
createdTimes := make(map[string]time.Time, len(allContainers))
|
||||
for _, ctr := range allContainers {
|
||||
createdTimes[ctr.Container.ID], _ = time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
||||
}
|
||||
|
||||
// Sort containers by created time (newest first).
|
||||
slices.SortFunc(allContainers, func(a, b api.MachineServiceContainer) int {
|
||||
return createdTimes[b.Container.ID].Compare(createdTimes[a.Container.ID])
|
||||
})
|
||||
|
||||
// Print the list of containers in a table format.
|
||||
// Show HOOK column only when hook containers are present.
|
||||
hasHooks := len(svc.HookContainers) > 0
|
||||
|
||||
t := tui.NewTable()
|
||||
if hasHooks {
|
||||
t.Headers("CONTAINER ID", "IMAGE", "CREATED", "STATUS", "HOOK", "IP ADDRESS", "MACHINE")
|
||||
} else {
|
||||
t.Headers("CONTAINER ID", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, ctr := range allContainers {
|
||||
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
|
||||
|
||||
machine := ctr.MachineName
|
||||
if machine == "" {
|
||||
machine = ctr.MachineID
|
||||
}
|
||||
state, err := ctr.Container.HumanState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get human state: %w", err)
|
||||
}
|
||||
|
||||
ip := ctr.Container.UncloudNetworkIP()
|
||||
ipStr := ""
|
||||
// The container might not have an IP if it's not running or uses the host network.
|
||||
if ip.IsValid() {
|
||||
ipStr = ip.String()
|
||||
}
|
||||
|
||||
if hasHooks {
|
||||
t.Row(
|
||||
stringid.TruncateID(ctr.Container.ID),
|
||||
tui.FormatImage(ctr.Container.Config.Image, tui.NoStyle),
|
||||
created,
|
||||
state,
|
||||
ctr.Container.Config.Labels[api.LabelHook],
|
||||
ipStr,
|
||||
machine,
|
||||
)
|
||||
} else {
|
||||
t.Row(
|
||||
stringid.TruncateID(ctr.Container.ID),
|
||||
tui.FormatImage(ctr.Container.Config.Image, tui.NoStyle),
|
||||
created,
|
||||
state,
|
||||
ipStr,
|
||||
machine,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewLogsCommand(groupID string) *cobra.Command {
|
||||
var options logs.Options
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs [SERVICE[/CONTAINER]...]",
|
||||
Aliases: []string{"log"},
|
||||
Short: "View service logs.",
|
||||
Long: `View logs from all replicas of the specified service(s) across all machines in the cluster.
|
||||
|
||||
To view logs from specific replicas (containers) within a service, use the SERVICE/CONTAINER form,
|
||||
where CONTAINER is a container name, full ID, or unique ID prefix.
|
||||
|
||||
If no services are specified, streams logs from all services defined in the Compose file
|
||||
(compose.yaml by default or the file(s) specified with --file).`,
|
||||
Example: ` # View recent logs for a service.
|
||||
uc logs web
|
||||
|
||||
# Stream logs in real-time (follow mode).
|
||||
uc logs -f web
|
||||
|
||||
# View logs from multiple services.
|
||||
uc logs web api db
|
||||
|
||||
# View logs from all services in compose.yaml.
|
||||
uc logs
|
||||
|
||||
# Show last 20 lines per replica (default is 100).
|
||||
uc logs -n 20 web
|
||||
|
||||
# Show all logs without line limit.
|
||||
uc logs -n all web
|
||||
|
||||
# View logs from a specific time range.
|
||||
uc logs --since 3h --until 1h30m web
|
||||
|
||||
# View logs only from specific replicas (containers).
|
||||
uc logs web/61d57fd3428f api/2f60
|
||||
|
||||
# View logs only from replicas running on specific machines.
|
||||
uc logs -m machine1,machine2 web api`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return RunLogs(cmd.Context(), uncli, args, options)
|
||||
},
|
||||
GroupID: groupID,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Services(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVar(&options.Files, "file", nil,
|
||||
"One or more Compose files to load service names from when no services are specified. (default compose.yaml)")
|
||||
|
||||
cmd.Flags().AddFlagSet(logs.Flags(&options))
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
|
||||
serviceArgs, err := logs.ParseServiceArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If no services specified, try to load them from the Compose file(s).
|
||||
fromCompose := false
|
||||
if len(serviceArgs) == 0 {
|
||||
fromCompose = true
|
||||
project, err := compose.LoadProject(ctx, opts.Files)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Compose file(s): %w", err)
|
||||
}
|
||||
|
||||
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
|
||||
|
||||
// View logs for all services, including disabled by inactive profiles.
|
||||
composeServices := append(project.ServiceNames(), project.DisabledServiceNames()...)
|
||||
if len(composeServices) == 0 {
|
||||
return errors.New("no services found in Compose file(s)")
|
||||
}
|
||||
|
||||
serviceArgs = make([]logs.ServiceArg, len(composeServices))
|
||||
for i, name := range composeServices {
|
||||
serviceArgs[i] = logs.ServiceArg{Service: name}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tail option.
|
||||
tail, err := logs.Tail(opts.Tail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
baseOpts := api.ServiceLogsOptions{
|
||||
Follow: opts.Follow,
|
||||
Tail: tail,
|
||||
Since: opts.Since,
|
||||
Until: opts.Until,
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
||||
}
|
||||
|
||||
// Collect log streams from all services. When service names come from a Compose file,
|
||||
// skip the ones that are not found in the cluster (they may have been removed or not deployed yet).
|
||||
machineIDsSet := mapset.NewSet[string]()
|
||||
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceArgs))
|
||||
var foundServices, notFoundServices []string
|
||||
|
||||
for _, sa := range serviceArgs {
|
||||
svcOpts := baseOpts
|
||||
svcOpts.Containers = sa.Containers
|
||||
|
||||
svc, ch, err := c.ServiceLogs(ctx, sa.Service, svcOpts)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNotFound) && fromCompose {
|
||||
notFoundServices = append(notFoundServices, sa.Service)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("stream logs for service '%s': %w", sa.Service, err)
|
||||
}
|
||||
svcStreams = append(svcStreams, ch)
|
||||
foundServices = append(foundServices, sa.Service)
|
||||
|
||||
machineIDs := svc.MachineIDs()
|
||||
machineIDsSet.Append(machineIDs...)
|
||||
}
|
||||
|
||||
if fromCompose {
|
||||
if len(foundServices) == 0 {
|
||||
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
|
||||
strings.Join(opts.Files, ", "))
|
||||
}
|
||||
|
||||
for _, name := range notFoundServices {
|
||||
tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
|
||||
}
|
||||
}
|
||||
|
||||
var stream <-chan api.ServiceLogEntry
|
||||
if len(svcStreams) == 1 {
|
||||
stream = svcStreams[0]
|
||||
} else {
|
||||
// Merge all service streams into a single sorted stream without stall detection as its handled per-service.
|
||||
merger := client.NewLogMerger(svcStreams, client.LogMergerOptions{})
|
||||
stream = merger.Stream()
|
||||
}
|
||||
|
||||
// Fetch machine names for all machines (machineIDsSet) service containers are running on.
|
||||
// Note: this is the full set per service, not narrowed by --machine or per-container filters,
|
||||
// so the formatter may pad columns wider than strictly needed when filters are active.
|
||||
machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: machineIDsSet.ToSlice()})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
machineNames := make([]string, 0, len(machines))
|
||||
for _, m := range machines {
|
||||
machineNames = append(machineNames, m.Machine.Name)
|
||||
}
|
||||
|
||||
formatter := logs.NewFormatter(machineNames, foundServices, opts.UTC)
|
||||
|
||||
// Print merged logs.
|
||||
for entry := range stream {
|
||||
formatter.PrintEntry(entry)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewListCommand(groupID string) *cobra.Command {
|
||||
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)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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)
|
||||
}
|
||||
|
||||
// Sort services by name.
|
||||
haveDuplicateNames := false
|
||||
slices.SortFunc(services, func(a, b api.Service) int {
|
||||
if a.Name == b.Name {
|
||||
haveDuplicateNames = true
|
||||
return strings.Compare(a.ID, b.ID)
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
// Print the list of services in a table format.
|
||||
t := tui.NewTable()
|
||||
|
||||
// Include the ID column if there are duplicate service names to differentiate them.
|
||||
headers := []string{"NAME", "MODE", "REPLICAS", "IMAGE", "ENDPOINTS"}
|
||||
if haveDuplicateNames {
|
||||
headers = append([]string{"ID"}, headers...)
|
||||
}
|
||||
t.Headers(headers...)
|
||||
|
||||
for _, s := range services {
|
||||
images := s.Images()
|
||||
for i, img := range images {
|
||||
images[i] = tui.FormatImage(img, tui.NoStyle)
|
||||
}
|
||||
formattedImages := strings.Join(images, tui.Faint.Render(", "))
|
||||
endpoints := strings.Join(s.Endpoints(), tui.Faint.Render(", "))
|
||||
|
||||
// If no endpoints from ports, check if the service uses custom Caddy config.
|
||||
if endpoints == "" {
|
||||
for _, ctr := range s.Containers {
|
||||
if ctr.Container.ServiceSpec.CaddyConfig() != "" {
|
||||
endpoints = "(custom Caddy config)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row := []string{s.Name, s.Mode, fmt.Sprintf("%d", len(s.Containers)), formattedImages, endpoints}
|
||||
if haveDuplicateNames {
|
||||
row = append([]string{s.ID}, row...)
|
||||
}
|
||||
t.Row(row...)
|
||||
}
|
||||
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type rmOptions struct {
|
||||
services []string
|
||||
}
|
||||
|
||||
func NewRmCommand(groupID string) *cobra.Command {
|
||||
opts := rmOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm SERVICE [SERVICE...]",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Remove one or more services.",
|
||||
Long: `Remove one or more services.
|
||||
|
||||
The volumes used by the services are preserved and should be removed separately
|
||||
with 'uc volume rm'. Anonymous Docker volumes (automatically created from VOLUME
|
||||
directives in image Dockerfiles) are automatically removed with their containers.`,
|
||||
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)
|
||||
},
|
||||
GroupID: groupID,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Services(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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 err
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "service",
|
||||
Aliases: []string{"svc"},
|
||||
Short: "Manage services in the cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewExecCommand(""),
|
||||
NewInspectCommand(""),
|
||||
NewListCommand(""),
|
||||
NewLogsCommand(""),
|
||||
NewRmCommand(""),
|
||||
NewRunCommand(""),
|
||||
NewScaleCommand(""),
|
||||
NewStartCommand(""),
|
||||
NewStopCommand(""),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
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/docker/go-units"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"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
|
||||
shmSize dockeropts.MemBytes
|
||||
name string
|
||||
privileged bool
|
||||
publish []string
|
||||
pull string
|
||||
replicas uint
|
||||
ulimits []string
|
||||
user string
|
||||
volumes []string
|
||||
}
|
||||
|
||||
func NewRunCommand(groupID string) *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)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
|
||||
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).\n"+
|
||||
"Default unit is bytes if no suffix specified.\n"+
|
||||
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
|
||||
cmd.Flags().VarP(&opts.shmSize, "shm-size", "",
|
||||
"Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer\n"+
|
||||
"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_prefix:]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\n"+
|
||||
" -p 192.168.76.0/24:53:5353/udp@host Bind UDP port 5353 to host port 53 on every host IP address\n"+
|
||||
" contained in the prefix 192.168.76.0/24")
|
||||
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().StringSliceVar(&opts.ulimits, "ulimit", nil,
|
||||
"Set resource limits for service containers. Can be specified multiple times.\n"+
|
||||
"Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.\n"+
|
||||
"Examples:\n"+
|
||||
" --ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files\n"+
|
||||
" --ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes")
|
||||
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")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
placement := api.Placement{
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||
}
|
||||
|
||||
ulimits, err := parseUlimits(opts.ulimits)
|
||||
if err != nil {
|
||||
return spec, err
|
||||
}
|
||||
|
||||
volumes, mounts, err := parseVolumeFlags(opts.volumes)
|
||||
if err != nil {
|
||||
return spec, err
|
||||
}
|
||||
|
||||
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(),
|
||||
SharedMemory: opts.shmSize.Value(),
|
||||
Ulimits: ulimits,
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
// parseUlimits parses ulimit flag values in Docker CLI format (type=soft[:hard]).
|
||||
func parseUlimits(ulimits []string) (map[string]api.Ulimit, error) {
|
||||
if len(ulimits) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make(map[string]api.Ulimit, len(ulimits))
|
||||
for _, u := range ulimits {
|
||||
parsed, err := units.ParseUlimit(u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ulimit '%s': %w", u, err)
|
||||
}
|
||||
result[parsed.Name] = api.Ulimit{Soft: parsed.Soft, Hard: parsed.Hard}
|
||||
}
|
||||
|
||||
return result, 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.SplitSeq(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,175 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type scaleOptions struct {
|
||||
service string
|
||||
replicas uint
|
||||
yes bool
|
||||
}
|
||||
|
||||
func NewScaleCommand(groupID string) *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.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
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)
|
||||
},
|
||||
GroupID: groupID,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Services(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm scaling plan. Should be explicitly set when running non-interactively,\n"+
|
||||
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
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)
|
||||
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 %s replicas. No changes required.\n",
|
||||
tui.NameStyle.Render(svc.Name), tui.Bold.Render(fmt.Sprintf("%d", 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", tui.NameStyle.Render(svc.Name), opts.replicas)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println(tui.Bold.Underline(true).Render("Scaling plan"))
|
||||
fmt.Println()
|
||||
|
||||
directConn := uncli.DirectConnection()
|
||||
contextName := uncli.ContextOverrideOrCurrent()
|
||||
deployTarget := ""
|
||||
if directConn != "" {
|
||||
deployTarget = directConn
|
||||
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
|
||||
fmt.Println()
|
||||
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
|
||||
// Only show context if there's more than one to avoid unnecessary clutter.
|
||||
deployTarget = contextName
|
||||
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println(plan.Format())
|
||||
|
||||
summary := plan.FormatSummary()
|
||||
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
|
||||
fmt.Println(summary)
|
||||
fmt.Println()
|
||||
|
||||
// Ask for confirmation unless auto-confirmed with --yes.
|
||||
if !opts.yes {
|
||||
if !tui.IsTerminalAvailable() {
|
||||
return errors.New("cannot ask to confirm scaling plan in non-interactive mode, " +
|
||||
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
|
||||
}
|
||||
|
||||
title := "Proceed with scaling?"
|
||||
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
|
||||
// scaling on the wrong cluster.
|
||||
if deployTarget != "" {
|
||||
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
|
||||
title = "Proceed with scaling on " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
|
||||
}
|
||||
|
||||
confirmed, err := tui.Confirm(title)
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm scaling: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
return cli.Cancelled("Scaling cancelled. No changes were made.")
|
||||
}
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)",
|
||||
tui.NameStyle.Render(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
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//nolint:dupl
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type startOptions struct {
|
||||
services []string
|
||||
}
|
||||
|
||||
func NewStartCommand(groupID string) *cobra.Command {
|
||||
opts := startOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "start SERVICE [SERVICE...]",
|
||||
Short: "Start one or more services.",
|
||||
Long: `Start one or more previously stopped services.
|
||||
|
||||
Starts all containers of the specified service(s) across all machines in the cluster.
|
||||
Services can be specified by name or ID.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.services = args
|
||||
return start(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: groupID,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Services(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func start(ctx context.Context, uncli *cli.CLI, opts startOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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.StartService(ctx, s); err != nil {
|
||||
return fmt.Errorf("start service '%s': %w", s, err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), "Starting service "+s)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//nolint:dupl
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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/cli/completion"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type stopOptions struct {
|
||||
services []string
|
||||
signal string
|
||||
timeout int
|
||||
}
|
||||
|
||||
func NewStopCommand(groupID string) *cobra.Command {
|
||||
opts := stopOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop SERVICE [SERVICE...]",
|
||||
Short: "Stop one or more services.",
|
||||
Long: `Stop one or more running services.
|
||||
|
||||
Gracefully stops all containers of the specified service(s) across all machines in the cluster.
|
||||
Services can be specified by name or ID. Stopped services can be restarted with 'uc start'.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.services = args
|
||||
return stop(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: groupID,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Services(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.signal, "signal", "s", "",
|
||||
"Signal to send to each container's main process.\n"+
|
||||
"Can be a signal name (SIGTERM, SIGINT, SIGHUP, etc.) or a number. (default SIGTERM)")
|
||||
cmd.Flags().IntVarP(&opts.timeout, "timeout", "t", 10,
|
||||
"Seconds to wait for each container to stop gracefully before forcibly killing it with SIGKILL.\n"+
|
||||
"Use -1 to wait indefinitely.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func stop(ctx context.Context, uncli *cli.CLI, opts stopOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
stopOpts := container.StopOptions{
|
||||
Signal: opts.signal,
|
||||
Timeout: &opts.timeout,
|
||||
}
|
||||
|
||||
for _, s := range opts.services {
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err = client.StopService(ctx, s, stopOpts); err != nil {
|
||||
return fmt.Errorf("stop service '%s': %w", s, err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), "Stopping service "+s)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
//go:embed art.txt
|
||||
var asciiArt string
|
||||
|
||||
// NewVersionCommand creates a new command to print the version and build information for the binary.
|
||||
func NewVersionCommand() *cobra.Command {
|
||||
var output string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show version and build information.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
info := version.GetInfo()
|
||||
w := cmd.OutOrStdout()
|
||||
|
||||
switch output {
|
||||
case "":
|
||||
fmt.Fprint(w, humanVersion(info))
|
||||
case "json":
|
||||
s, err := info.JSONString()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w, s)
|
||||
default:
|
||||
s, err := templateVersion(output, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprint(w, s)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&output, "output", "o", "",
|
||||
"Output format: 'json' or a Go template (e.g. '{{.Version}}').\n"+
|
||||
"Run with '-o json' to discover the field names available to the template.\n"+
|
||||
"(default is human-readable)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func humanVersion(info version.Info) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(asciiArt)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf("uc: Uncloud CLI tool for deploying apps and managing resources (%s)",
|
||||
tui.URLStyle.Render(version.WebsiteURL)))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(info.String())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// templateVersion renders the version info using the provided Go template.
|
||||
func templateVersion(tmpl string, info version.Info) (string, error) {
|
||||
t, err := template.New("version").Parse(tmpl)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err = t.Execute(&buf, info); err != nil {
|
||||
return "", fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"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
|
||||
}
|
||||
|
||||
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.")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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,88 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type inspectOptions struct {
|
||||
machine 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)
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Volumes(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
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.")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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,101 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listOptions struct {
|
||||
machines []string
|
||||
quiet bool
|
||||
}
|
||||
|
||||
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.")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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.
|
||||
t := tui.NewTable()
|
||||
t.Headers("NAME", "DRIVER", "MACHINE")
|
||||
|
||||
for _, v := range volumes {
|
||||
t.Row(v.Volume.Name, v.Volume.Driver, v.MachineName)
|
||||
}
|
||||
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type removeOptions struct {
|
||||
force bool
|
||||
machines []string
|
||||
yes bool
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Volumes(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
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).")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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 := tui.Confirm("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm removal: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
return cli.Cancelled("Cancelled. No volumes were removed.")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 the cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewCreateCommand(),
|
||||
NewInspectCommand(),
|
||||
NewListCommand(),
|
||||
NewRemoveCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package wg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "wg",
|
||||
Short: "Inspect WireGuard network",
|
||||
}
|
||||
cmd.AddCommand(newShowCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
type showOptions struct {
|
||||
machine string
|
||||
}
|
||||
|
||||
func newShowCommand() *cobra.Command {
|
||||
opts := showOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show WireGuard network configuration for a machine.",
|
||||
Long: "Show the WireGuard network configuration for the machine currently connected to " +
|
||||
"(or specified by the global --connect flag).",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return runShow(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||
"Name or ID of the machine to show the configuration for. (default is connected machine)")
|
||||
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if opts.machine != "" {
|
||||
// Proxy requests to the specified machine.
|
||||
ctx = client.ProxySingleMachineContext(ctx, opts.machine)
|
||||
}
|
||||
|
||||
resp, err := client.MachineClient.InspectWireGuardNetwork(ctx, nil)
|
||||
if err != nil {
|
||||
if status.Code(err) == codes.Unimplemented {
|
||||
return fmt.Errorf("inspect WireGuard network: "+
|
||||
"make sure the target machine is running uncloudd daemon version >= 0.16.0: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
machines, err := client.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
machinesByPublicKey := make(map[string]*pb.MachineInfo)
|
||||
for _, m := range machines {
|
||||
publicKey := wgtypes.Key(m.Machine.Network.PublicKey).String()
|
||||
machinesByPublicKey[publicKey] = m.Machine
|
||||
}
|
||||
|
||||
// Fetch the machine's info and RTTs for display.
|
||||
var selfMachine *pb.MachineDetails
|
||||
inspectResp, err := client.MachineClient.InspectMachine(ctx, nil)
|
||||
if err == nil {
|
||||
selfMachine = inspectResp.Machines[0]
|
||||
fmt.Printf("Machine name: %s\n", selfMachine.Machine.Name)
|
||||
}
|
||||
|
||||
fmt.Printf("WireGuard interface: %s\n", resp.InterfaceName)
|
||||
fmt.Printf("WireGuard public key: %s\n", wgtypes.Key(resp.PublicKey).String())
|
||||
fmt.Printf("WireGuard port: %d\n", resp.ListenPort)
|
||||
fmt.Println()
|
||||
|
||||
if len(resp.Peers) == 0 {
|
||||
fmt.Println("No WireGuard peers configured.")
|
||||
return nil
|
||||
}
|
||||
|
||||
t := tui.NewTable()
|
||||
t.Headers("PEER", "PUBLIC KEY", "ENDPOINT", "HANDSHAKE", "RTT", "RECEIVED", "SENT", "ALLOWED IPS")
|
||||
|
||||
for _, peer := range resp.Peers {
|
||||
publicKeyStr := wgtypes.Key(peer.PublicKey).String()
|
||||
machineName := "(unknown)"
|
||||
rtt := "-"
|
||||
if m, ok := machinesByPublicKey[publicKeyStr]; ok {
|
||||
machineName = m.Name
|
||||
if selfMachine != nil {
|
||||
if stats, ok := selfMachine.Rtts[m.Id]; ok {
|
||||
rtt = tui.FormatRTT(stats.Median.AsDuration())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastHandshake := ""
|
||||
if peer.LastHandshakeTime != nil {
|
||||
lastHandshake = time.Since(peer.LastHandshakeTime.AsTime()).Round(time.Second).String() + " ago"
|
||||
}
|
||||
|
||||
t.Row(
|
||||
machineName,
|
||||
publicKeyStr,
|
||||
peer.Endpoint,
|
||||
lastHandshake,
|
||||
rtt,
|
||||
units.HumanSize(float64(peer.ReceiveBytes)),
|
||||
units.HumanSize(float64(peer.TransmitBytes)),
|
||||
strings.Join(peer.AllowedIps, tui.Faint.Render(", ")),
|
||||
)
|
||||
}
|
||||
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
@@ -2,8 +2,9 @@ package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/ucind"
|
||||
)
|
||||
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List clusters.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := cmd.Context().Value("provisioner").(*ucind.Provisioner)
|
||||
clusters, err := p.ListClusters(cmd.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("list clusters: %w", err)
|
||||
}
|
||||
t := tui.NewTable()
|
||||
t.Headers("NAME", "MACHINES")
|
||||
for _, cluster := range clusters {
|
||||
machines := []string{}
|
||||
for _, m := range cluster.Machines {
|
||||
machines = append(machines, m.Name)
|
||||
}
|
||||
t.Row(
|
||||
cluster.Name,
|
||||
strings.Join(machines, tui.Faint.Render(", ")),
|
||||
)
|
||||
}
|
||||
fmt.Println(t)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -2,15 +2,18 @@ package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
"github.com/psviderski/uncloud/internal/ucind/completion"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/ucind"
|
||||
)
|
||||
|
||||
func NewRemoveCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm [NAME]",
|
||||
Short: "Remove a cluster.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Use: "rm [NAME]",
|
||||
Short: "Remove a cluster.",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := cmd.Context().Value("provisioner").(*ucind.Provisioner)
|
||||
|
||||
@@ -25,6 +28,12 @@ func NewRemoveCommand() *cobra.Command {
|
||||
fmt.Printf("Cluster '%s' removed.\n", name)
|
||||
return nil
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return completion.Clusters(cmd, args, toComplete)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func NewRootCommand() *cobra.Command {
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewCreateCommand(),
|
||||
//NewListCommand(),
|
||||
NewListCommand(),
|
||||
NewRemoveCommand(),
|
||||
)
|
||||
return cmd
|
||||
|
||||
+11
-8
@@ -3,12 +3,14 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"strings"
|
||||
"uncloud/cmd/ucind/cluster"
|
||||
"uncloud/internal/ucind"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/psviderski/uncloud/cmd/ucind/cluster"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -22,6 +24,8 @@ func main() {
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
|
||||
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create Docker client: %w", err)
|
||||
@@ -43,10 +47,9 @@ func main() {
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.toml",
|
||||
"path to the Uncloud configuration file.")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "toml")
|
||||
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||
|
||||
cmd.AddCommand(
|
||||
cluster.NewRootCommand(),
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/cli/config"
|
||||
)
|
||||
|
||||
type addOptions struct {
|
||||
name string
|
||||
sshKey string
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewAddCommand() *cobra.Command {
|
||||
opts := addOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "add [USER@]HOST[:PORT]",
|
||||
Short: "Add a remote machine to a cluster.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
user, host, port, err := config.SSHDestination(args[0]).Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse remote machine: %w", err)
|
||||
}
|
||||
remoteMachine := cli.RemoteMachine{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
}
|
||||
|
||||
return uncli.AddMachine(cmd.Context(), remoteMachine, opts.cluster, opts.name)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Assign a name to the machine.")
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.sshKey, "ssh-key", "i", "",
|
||||
"path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "cluster", "c", "",
|
||||
"Name of the cluster to add the machine to. (default is the current cluster)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"net/netip"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/cli/config"
|
||||
"uncloud/internal/machine/cluster"
|
||||
)
|
||||
|
||||
type initOptions struct {
|
||||
name string
|
||||
network string
|
||||
sshKey string
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewInitCommand() *cobra.Command {
|
||||
opts := initOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "init [USER@HOST:PORT]",
|
||||
Short: "Initialise a new cluster that consists of the local or remote machine.",
|
||||
// TODO: include usage examples of initialising a local and remote machine.
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
var remoteMachine *cli.RemoteMachine
|
||||
if len(args) > 0 {
|
||||
user, host, port, err := config.SSHDestination(args[0]).Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse remote machine: %w", err)
|
||||
}
|
||||
remoteMachine = &cli.RemoteMachine{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
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)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Assign a name to the machine.")
|
||||
cmd.Flags().StringVar(
|
||||
&opts.network, "network", cluster.DefaultNetwork.String(),
|
||||
"IPv4 network CIDR to use for machines and services.",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.sshKey, "ssh-key", "i", "",
|
||||
"path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "cluster", "c", "",
|
||||
"Name of the cluster in the local config if initialising a remote machine.",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/machine/network"
|
||||
"uncloud/internal/secret"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
var cluster 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, cluster)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&cluster, "cluster", "c", "",
|
||||
"Name of the cluster. (default is the current cluster)",
|
||||
)
|
||||
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)
|
||||
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 KEY\tENDPOINTS"); 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())
|
||||
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\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicKey, 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:])
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/daemon"
|
||||
"uncloud/internal/machine"
|
||||
)
|
||||
|
||||
type tokenOptions struct {
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewTokenCommand() *cobra.Command {
|
||||
opts := tokenOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "token",
|
||||
Short: "Print the local machine's token for adding it to a cluster.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
token, err := daemon.MachineToken(opts.dataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get machine token: %w", err)
|
||||
}
|
||||
tokenStr, err := token.String()
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode machine token: %w", err)
|
||||
}
|
||||
fmt.Println(tokenStr)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.dataDir, "data-dir", "d", machine.DefaultDataDir,
|
||||
"Directory for storing persistent machine state.")
|
||||
_ = cmd.MarkFlagDirname("data-dir")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"strings"
|
||||
"uncloud/cmd/uncloud/machine"
|
||||
"uncloud/cmd/uncloud/service"
|
||||
"uncloud/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath string
|
||||
cmd := &cobra.Command{
|
||||
Use: "uncloud",
|
||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
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)
|
||||
}
|
||||
|
||||
uncli, err := cli.New(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize CLI: %w", err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), "cli", uncli))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.toml",
|
||||
"path to the Uncloud configuration file.")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "toml")
|
||||
|
||||
cmd.AddCommand(
|
||||
machine.NewRootCommand(),
|
||||
service.NewRootCommand(),
|
||||
service.NewInspectCommand(),
|
||||
service.NewListCommand(),
|
||||
service.NewRmCommand(),
|
||||
service.NewRunCommand(),
|
||||
)
|
||||
cobra.CheckErr(cmd.Execute())
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/docker/go-units"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/cli"
|
||||
)
|
||||
|
||||
type inspectOptions struct {
|
||||
service string
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewInspectCommand() *cobra.Command {
|
||||
opts := inspectOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "inspect",
|
||||
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, "cluster", "c", "",
|
||||
"Name of the cluster. (default is the current cluster)",
|
||||
)
|
||||
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)
|
||||
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 := time.Unix(ctr.Container.Created, 0)
|
||||
created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
||||
|
||||
machine := machinesNamesByID[ctr.MachineID]
|
||||
if machine == "" {
|
||||
machine = ctr.MachineID
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(
|
||||
tw,
|
||||
"%s\t%s\t%s\t%s\t%s\n",
|
||||
stringid.TruncateID(ctr.Container.ID),
|
||||
ctr.Container.Image,
|
||||
created,
|
||||
ctr.Container.Status,
|
||||
machine,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
"uncloud/internal/cli"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
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, "cluster", "c", "",
|
||||
"Name of the cluster. (default is the current cluster)",
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
// Print the list of services in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
if _, err = fmt.Fprintln(tw, "SERVICE ID\tNAME\tMODE\tREPLICAS"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
for _, s := range services {
|
||||
if _, err = fmt.Fprintf(tw, "%s\t%s\t%s\t%d\n", s.ID, s.Name, s.Mode, len(s.Containers)); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/cli"
|
||||
)
|
||||
|
||||
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, "cluster", "c", "",
|
||||
"Name of the cluster. (default is the current cluster)",
|
||||
)
|
||||
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 {
|
||||
if err = client.RemoveService(ctx, s); err != nil {
|
||||
return fmt.Errorf("remove service %q: %w", s, err)
|
||||
}
|
||||
fmt.Printf("Service %q removed.\n", s)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/api"
|
||||
"uncloud/internal/cli"
|
||||
)
|
||||
|
||||
type runOptions struct {
|
||||
command []string
|
||||
image string
|
||||
machine string
|
||||
mode string
|
||||
name string
|
||||
publish []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.image = args[0]
|
||||
if len(args) > 1 {
|
||||
opts.command = args[1:]
|
||||
}
|
||||
|
||||
return run(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: implement placement constraints and translate --machine to a constraint.
|
||||
//cmd.Flags().StringVarP(
|
||||
// &opts.machine, "machine", "m", "",
|
||||
// "Name or ID of the machine to run the service on. (default is first available)",
|
||||
//)
|
||||
cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
|
||||
fmt.Sprintf("Replication mode of the service: either %q (a specified number of containers across "+
|
||||
"the machines) or %q (one container on every machine).",
|
||||
api.ServiceModeReplicated, api.ServiceModeGlobal))
|
||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
||||
"Assign a name to the service. A random name is generated if not specified.")
|
||||
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:][load_balancer_port:]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, a random hostname is generated.\n"+
|
||||
"Examples:\n"+
|
||||
" -p app.example.com:8080/https Publish port 8080 as HTTPS via load balancer with custom hostname\n"+
|
||||
" -p 9000:8080 Publish port 8080 as TCP port 9000 via load balancer\n"+
|
||||
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
|
||||
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
|
||||
"Bind mount a host file or directory into a service container using the format "+
|
||||
"/host/path:/container/path[:ro]. Can be specified multiple times.")
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "cluster", "c", "",
|
||||
"Name of the cluster to run the service in. (default is the current cluster)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
||||
switch opts.mode {
|
||||
case "", api.ServiceModeReplicated, api.ServiceModeGlobal:
|
||||
default:
|
||||
return fmt.Errorf("invalid replication mode: %q", opts.mode)
|
||||
}
|
||||
|
||||
ports := make([]api.PortSpec, len(opts.publish))
|
||||
for i, publishPort := range opts.publish {
|
||||
port, err := api.ParsePortSpec(publishPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid service port '%s': %w", publishPort, err)
|
||||
}
|
||||
ports[i] = port
|
||||
}
|
||||
// TODO: parse and validate opts.volumes to fail fast if invalid.
|
||||
|
||||
spec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Command: opts.command,
|
||||
Image: opts.image,
|
||||
Volumes: opts.volumes,
|
||||
},
|
||||
Mode: opts.mode,
|
||||
Name: opts.name,
|
||||
Ports: ports,
|
||||
}
|
||||
if err := spec.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid service configuration: %w", err)
|
||||
}
|
||||
|
||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if _, err = client.RunService(ctx, spec); err != nil {
|
||||
return fmt.Errorf("run service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
_ _ _
|
||||
_ _ _ __ ___| | ___ _ _ __| | __| |
|
||||
| | | | '_ \ / __| |/ _ \| | | |/ _` |/ _` |
|
||||
| |_| | | | | (__| | (_) | |_| | (_| | (_| |
|
||||
\__,_|_| |_|\___|_|\___/ \__,_|\__,_|\__,_|
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newDialStdioCommand() *cobra.Command {
|
||||
var socketPath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "dial-stdio",
|
||||
Short: "Proxy stdin/stdout to the Uncloud API socket",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runDialStdio(cmd.Context(), socketPath, os.Stdin, os.Stdout)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultUncloudSockPath,
|
||||
"Path to the Uncloud API socket")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// halfReadCloser is the read side of a half-duplex connection.
|
||||
type halfReadCloser interface {
|
||||
io.Reader
|
||||
CloseRead() error
|
||||
}
|
||||
|
||||
// halfWriteCloser is the write side of a half-duplex connection.
|
||||
type halfWriteCloser interface {
|
||||
io.Writer
|
||||
CloseWrite() error
|
||||
}
|
||||
|
||||
// halfReadCloserWrapper wraps an io.ReadCloser to implement halfReadCloser.
|
||||
type halfReadCloserWrapper struct {
|
||||
io.ReadCloser
|
||||
}
|
||||
|
||||
func (x *halfReadCloserWrapper) CloseRead() error {
|
||||
return x.Close()
|
||||
}
|
||||
|
||||
// halfWriteCloserWrapper wraps an io.WriteCloser to implement halfWriteCloser.
|
||||
type halfWriteCloserWrapper struct {
|
||||
io.WriteCloser
|
||||
}
|
||||
|
||||
func (x *halfWriteCloserWrapper) CloseWrite() error {
|
||||
return x.Close()
|
||||
}
|
||||
|
||||
func runDialStdio(ctx context.Context, socketPath string, stdin io.Reader, stdout io.Writer) error {
|
||||
// Connect to the unix socket.
|
||||
var dialer net.Dialer
|
||||
conn, err := dialer.DialContext(ctx, "unix", socketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to socket %q: %w", socketPath, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Wrap stdin/stdout to support half-closing.
|
||||
var stdinCloser halfReadCloser
|
||||
if c, ok := stdin.(halfReadCloser); ok {
|
||||
stdinCloser = c
|
||||
} else if c, ok := stdin.(io.ReadCloser); ok {
|
||||
stdinCloser = &halfReadCloserWrapper{c}
|
||||
}
|
||||
|
||||
var stdoutCloser halfWriteCloser
|
||||
if c, ok := stdout.(halfWriteCloser); ok {
|
||||
stdoutCloser = c
|
||||
} else if c, ok := stdout.(io.WriteCloser); ok {
|
||||
stdoutCloser = &halfWriteCloserWrapper{c}
|
||||
}
|
||||
|
||||
// Copy data bidirectionally between stdin/stdout and the socket.
|
||||
stdin2socket := make(chan error, 1)
|
||||
socket2stdout := make(chan error, 1)
|
||||
|
||||
// Copy from stdin to socket.
|
||||
go func() {
|
||||
_, err := io.Copy(conn, stdin)
|
||||
stdin2socket <- err
|
||||
// Close write side of connection after stdin is done.
|
||||
if unixConn, ok := conn.(*net.UnixConn); ok {
|
||||
unixConn.CloseWrite()
|
||||
}
|
||||
if stdinCloser != nil {
|
||||
stdinCloser.CloseRead()
|
||||
}
|
||||
}()
|
||||
|
||||
// Copy from socket to stdout.
|
||||
go func() {
|
||||
_, err := io.Copy(stdout, conn)
|
||||
socket2stdout <- err
|
||||
// Close read side of connection after socket is done sending.
|
||||
if unixConn, ok := conn.(*net.UnixConn); ok {
|
||||
unixConn.CloseRead()
|
||||
}
|
||||
if stdoutCloser != nil {
|
||||
stdoutCloser.CloseWrite()
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-stdin2socket:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// wait for stdout
|
||||
err = <-socket2stdout
|
||||
case err = <-socket2stdout:
|
||||
// return immediately, matching Docker's approach
|
||||
// (stdin is never closed when TTY)
|
||||
}
|
||||
return err
|
||||
}
|
||||
+15
-6
@@ -2,14 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/spf13/cobra"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"uncloud/internal/daemon"
|
||||
"uncloud/internal/log"
|
||||
"uncloud/internal/machine"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/daemon"
|
||||
"github.com/psviderski/uncloud/internal/log"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/metrics"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -22,6 +26,7 @@ func main() {
|
||||
cmd := &cobra.Command{
|
||||
Use: "uncloudd",
|
||||
Short: "Uncloud machine daemon.",
|
||||
Long: "Uncloud machine daemon.\n" + tui.URLStyle.Render(version.WebsiteURL),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -36,9 +41,14 @@ func main() {
|
||||
},
|
||||
}
|
||||
cmd.PersistentFlags().StringVarP(&dataDir, "data-dir", "d", machine.DefaultDataDir,
|
||||
"Directory for storing persistent machine state")
|
||||
"Directory for storing persistent machine state.")
|
||||
_ = cmd.MarkFlagDirname("data-dir")
|
||||
|
||||
cmd.AddCommand(newDialStdioCommand())
|
||||
cmd.AddCommand(newVersionCommand())
|
||||
|
||||
metrics.Version.WithLabelValues(version.String()).Set(1)
|
||||
|
||||
// ctx is canceled when the daemon command is interrupted.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
@@ -52,5 +62,4 @@ func main() {
|
||||
}()
|
||||
|
||||
cobra.CheckErr(cmd.ExecuteContext(ctx))
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
//go:embed art.txt
|
||||
var asciiArt string
|
||||
|
||||
// newVersionCommand creates a new command to print the version and build information for the binary.
|
||||
func newVersionCommand() *cobra.Command {
|
||||
var output string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show version and build information.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
info := version.GetInfo()
|
||||
w := cmd.OutOrStdout()
|
||||
|
||||
switch output {
|
||||
case "":
|
||||
fmt.Fprint(w, humanVersion(info))
|
||||
case "json":
|
||||
s, err := info.JSONString()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w, s)
|
||||
default:
|
||||
s, err := templateVersion(output, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprint(w, s)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&output, "output", "o", "",
|
||||
"Output format: 'json' or a Go template (e.g., '{{.Version}}').\n"+
|
||||
"Run with '-o json' to discover the field names available to the template.\n"+
|
||||
"(default is human-readable)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func humanVersion(info version.Info) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(asciiArt)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf("uncloudd: Uncloud machine daemon (%s)",
|
||||
tui.URLStyle.Render(version.WebsiteURL)))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(info.String())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// templateVersion renders the version info using the provided Go template.
|
||||
func templateVersion(tmpl string, info version.Info) (string, error) {
|
||||
t, err := template.New("version").Parse(tmpl)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err = t.Execute(&buf, info); err != nil {
|
||||
return "", fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -3,10 +3,11 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/hashicorp/serf/serf"
|
||||
crdt "github.com/ipfs/go-ds-crdt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/serf/serf"
|
||||
crdt "github.com/ipfs/go-ds-crdt"
|
||||
)
|
||||
|
||||
// Implements the Broadcaster interface.
|
||||
|
||||
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dgraph-io/badger/v3"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v3"
|
||||
)
|
||||
|
||||
func customTimeEncoder(t time.Time) string {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
module github.com/psviderski/uncloud/experiment
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||
github.com/docker/docker v28.5.0+incompatible
|
||||
github.com/hashicorp/memberlist v0.5.1
|
||||
github.com/hashicorp/serf v0.10.1
|
||||
github.com/ipfs/boxo v0.21.0
|
||||
github.com/ipfs/go-cid v0.4.1
|
||||
github.com/ipfs/go-datastore v0.6.0
|
||||
github.com/ipfs/go-ds-badger3 v0.0.2
|
||||
github.com/ipfs/go-ds-crdt v0.5.2
|
||||
github.com/ipfs/go-ipld-format v0.6.0
|
||||
github.com/ipfs/go-log/v2 v2.5.1
|
||||
github.com/lmittmann/tint v1.0.5
|
||||
github.com/psviderski/uncloud v0.0.0
|
||||
github.com/siderolabs/discovery-api v0.1.4
|
||||
github.com/siderolabs/discovery-client v0.1.9
|
||||
go.uber.org/zap v1.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/armon/go-radix v1.0.0 // indirect
|
||||
github.com/bgentry/speakeasy 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/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/glog v1.2.5 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.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/v2 v2.1.0 // indirect
|
||||
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.6 // indirect
|
||||
github.com/hashicorp/go-syslog v1.0.0 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/logutils v1.0.0 // indirect
|
||||
github.com/hashicorp/mdns v1.0.5 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/imdario/mergo v0.3.16 // indirect
|
||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||
github.com/ipfs/go-block-format v0.2.0 // indirect
|
||||
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
|
||||
github.com/ipfs/go-ipld-legacy v0.2.1 // indirect
|
||||
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
|
||||
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
|
||||
github.com/ipld/go-ipld-prime v0.21.0 // indirect
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect
|
||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // 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-pubsub v0.11.0 // indirect
|
||||
github.com/libp2p/go-msgio v0.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/miekg/dns v1.1.65 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mitchellh/cli v1.1.5 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
github.com/multiformats/go-multiaddr v0.13.0 // indirect
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect
|
||||
github.com/multiformats/go-multibase v0.2.0 // indirect
|
||||
github.com/multiformats/go-multicodec v0.9.0 // indirect
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-multistream v0.5.0 // indirect
|
||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/polydawn/refmt v0.89.0 // indirect
|
||||
github.com/posener/complete v1.2.3 // indirect
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/siderolabs/gen v0.4.8 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
|
||||
google.golang.org/grpc v1.74.2 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
lukechampine.com/blake3 v1.3.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/psviderski/uncloud => ../
|
||||
@@ -0,0 +1,782 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M=
|
||||
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk=
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
|
||||
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs=
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
|
||||
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E=
|
||||
github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
|
||||
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
|
||||
github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0=
|
||||
github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU=
|
||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/dgraph-io/badger v1.6.2 h1:mNw0qs90GVgGGWylh0umH5iag1j6n/PeJtNvL6KY/x8=
|
||||
github.com/dgraph-io/badger v1.6.2/go.mod h1:JW2yswe3V058sS0kZ2h/AXeDSqFjxnZcRrVH//y2UQE=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
|
||||
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/docker/docker v28.5.0+incompatible h1:ZdSQoRUE9XxhFI/B8YLvhnEFMmYN9Pp8Egd2qcaFk1E=
|
||||
github.com/docker/docker v28.5.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8=
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo=
|
||||
github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
|
||||
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
|
||||
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
|
||||
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw=
|
||||
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI=
|
||||
github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.2 h1:4Ee8FTp834e+ewB71RDrQ0VKpyFdrKOjvYtnQ/ltVj0=
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.2/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
|
||||
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I=
|
||||
github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI=
|
||||
github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE=
|
||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
|
||||
github.com/hashicorp/mdns v1.0.5 h1:1M5hW1cunYeoXOqHwEb/GBDDHAFo0Yqb/uz/beC6LbE=
|
||||
github.com/hashicorp/mdns v1.0.5/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
|
||||
github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0=
|
||||
github.com/hashicorp/memberlist v0.5.1 h1:mk5dRuzeDNis2bi6LLoQIXfMH7JQvAzt3mQD0vNZZUo=
|
||||
github.com/hashicorp/memberlist v0.5.1/go.mod h1:zGDXV6AqbDTKTM6yxW0I4+JtFzZAJVoIPvss4hV8F24=
|
||||
github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY=
|
||||
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
|
||||
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
||||
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
|
||||
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
|
||||
github.com/ipfs/boxo v0.21.0 h1:XpGXb+TQQ0IUdYaeAxGzWjSs6ow/Lce148A/2IbRDVE=
|
||||
github.com/ipfs/boxo v0.21.0/go.mod h1:NmweAYeY1USOaJJxouy7DLr/Y5M8UBSsCI2KRivO+TY=
|
||||
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
|
||||
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
|
||||
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
|
||||
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
|
||||
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
|
||||
github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8=
|
||||
github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk=
|
||||
github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps=
|
||||
github.com/ipfs/go-ds-badger v0.3.0 h1:xREL3V0EH9S219kFFueOYJJTcjgNSZ2HY1iSvN7U1Ro=
|
||||
github.com/ipfs/go-ds-badger v0.3.0/go.mod h1:1ke6mXNqeV8K3y5Ak2bAA0osoTfmxUdupVCGm4QUIek=
|
||||
github.com/ipfs/go-ds-badger3 v0.0.2 h1:+pME0YfRnbUKhvySnakNMuCMsUUhmGfwIsH/nnHZ7QY=
|
||||
github.com/ipfs/go-ds-badger3 v0.0.2/go.mod h1:6/yjF1KaOU+IpCaqMV43yoWIdxHqOAJlO9EhWLnZSkI=
|
||||
github.com/ipfs/go-ds-crdt v0.5.2 h1:3cTJ79/UocAoukkXr0OvpPHcH7v4YwN8xCb8F9pfngc=
|
||||
github.com/ipfs/go-ds-crdt v0.5.2/go.mod h1:sJdQqdCp2Vwv1azk1sPl+Dxk780CQc11rf7B8aWmomc=
|
||||
github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ=
|
||||
github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk=
|
||||
github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ=
|
||||
github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
|
||||
github.com/ipfs/go-ipfs-pq v0.0.3 h1:YpoHVJB+jzK15mr/xsWC574tyDLkezVrDNeaalQBsTE=
|
||||
github.com/ipfs/go-ipfs-pq v0.0.3/go.mod h1:btNw5hsHBpRcSSgZtiNm/SLj5gYIZ18AKtv3kERkRb4=
|
||||
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
|
||||
github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
|
||||
github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U=
|
||||
github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg=
|
||||
github.com/ipfs/go-ipld-legacy v0.2.1 h1:mDFtrBpmU7b//LzLSypVrXsD8QxkEWxu5qVxN99/+tk=
|
||||
github.com/ipfs/go-ipld-legacy v0.2.1/go.mod h1:782MOUghNzMO2DER0FlBR94mllfdCJCkTtDtPM51otM=
|
||||
github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY=
|
||||
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
|
||||
github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg=
|
||||
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
|
||||
github.com/ipfs/go-peertaskqueue v0.8.1 h1:YhxAs1+wxb5jk7RvS0LHdyiILpNmRIRnZVztekOF0pg=
|
||||
github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU=
|
||||
github.com/ipld/go-codec-dagpb v1.6.0 h1:9nYazfyu9B1p3NAgfVdpRco3Fs2nFC72DqVsMj6rOcc=
|
||||
github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s=
|
||||
github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E=
|
||||
github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ=
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo=
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk=
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk=
|
||||
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
|
||||
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
|
||||
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0=
|
||||
github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
|
||||
github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg=
|
||||
github.com/libp2p/go-flow-metrics v0.1.0 h1:0iPhMI8PskQwzh57jB9WxIuIOQ0r+15PChFGkx3Q3WM=
|
||||
github.com/libp2p/go-flow-metrics v0.1.0/go.mod h1:4Xi8MX8wj5aWNDAZttg6UPmc0ZrnFNsMtpsYUClFtro=
|
||||
github.com/libp2p/go-libp2p v0.35.4 h1:FDiBUYLkueFwsuNJUZaxKRdpKvBOWU64qQPL768bSeg=
|
||||
github.com/libp2p/go-libp2p v0.35.4/go.mod h1:RKCDNt30IkFipGL0tl8wQW/3zVWEGFUZo8g2gAKxwjU=
|
||||
github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94=
|
||||
github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8=
|
||||
github.com/libp2p/go-libp2p-pubsub v0.11.0 h1:+JvS8Kty0OiyUiN0i8H5JbaCgjnJTRnTHe4rU88dLFc=
|
||||
github.com/libp2p/go-libp2p-pubsub v0.11.0/go.mod h1:QEb+hEV9WL9wCiUAnpY29FZR6W3zK8qYlaml8R4q6gQ=
|
||||
github.com/libp2p/go-libp2p-record v0.2.0 h1:oiNUOCWno2BFuxt3my4i1frNrt7PerzB3queqa1NkQ0=
|
||||
github.com/libp2p/go-libp2p-record v0.2.0/go.mod h1:I+3zMkvvg5m2OcSdoL0KPljyJyvNDFGKX7QdlpYUcwk=
|
||||
github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA=
|
||||
github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg=
|
||||
github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0=
|
||||
github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM=
|
||||
github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk=
|
||||
github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk=
|
||||
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
|
||||
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
|
||||
github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s=
|
||||
github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU=
|
||||
github.com/libp2p/go-yamux/v4 v4.0.1 h1:FfDR4S1wj6Bw2Pqbc8Uz7pCxeRBPbwsBbEdfwiCypkQ=
|
||||
github.com/libp2p/go-yamux/v4 v4.0.1/go.mod h1:NWjl8ZTLOGlozrXSOZ/HlfG++39iKNnM5wwmtQP1YB4=
|
||||
github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw=
|
||||
github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk=
|
||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
|
||||
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
|
||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
|
||||
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc=
|
||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKoFL8DUUmalo2yJJUCxbPKtm8OKfqr2/FTNU=
|
||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc h1:PTfri+PuQmWDqERdnNMiD9ZejrlswWrCpBEZgWOiTrc=
|
||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc/go.mod h1:cGKTAVKx4SxOuR/czcZ/E2RSJ3sfHs8FpHhQ5CWMf9s=
|
||||
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ=
|
||||
github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
|
||||
github.com/mitchellh/cli v1.1.5 h1:OxRIeJXpAMztws/XHlN2vu6imG5Dpq+j61AzAX5fLng=
|
||||
github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2cLtRR4=
|
||||
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
|
||||
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
|
||||
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
|
||||
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
|
||||
github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo=
|
||||
github.com/multiformats/go-multiaddr v0.13.0 h1:BCBzs61E3AGHcYYTv8dqRH43ZfyrqM8RXVPT8t13tLQ=
|
||||
github.com/multiformats/go-multiaddr v0.13.0/go.mod h1:sBXrNzucqkFJhvKOiwwLyqamGa/P5EIXNPLovyhQCII=
|
||||
github.com/multiformats/go-multiaddr-dns v0.3.1 h1:QgQgR+LQVt3NPTjbrLLpsaT2ufAA2y0Mkk+QRVJbW3A=
|
||||
github.com/multiformats/go-multiaddr-dns v0.3.1/go.mod h1:G/245BRQ6FJGmryJCrOuTdB37AMA5AMOVuO6NY3JwTk=
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E=
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo=
|
||||
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
|
||||
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
|
||||
github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg=
|
||||
github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k=
|
||||
github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew=
|
||||
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||
github.com/multiformats/go-multistream v0.5.0 h1:5htLSLl7lvJk3xx3qT/8Zm9J4K8vEOf/QGkvOGQAyiE=
|
||||
github.com/multiformats/go-multistream v0.5.0/go.mod h1:n6tMZiwiP2wUsR8DgfDWw1dydlEqV3l6N3/GBsX6ILA=
|
||||
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
|
||||
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
|
||||
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww=
|
||||
github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
|
||||
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pion/datachannel v1.5.6 h1:1IxKJntfSlYkpUj8LlYRSWpYiTTC02nUrOE8T3DqGeg=
|
||||
github.com/pion/datachannel v1.5.6/go.mod h1:1eKT6Q85pRnr2mHiWHxJwO50SfZRtWHTsNIVb/NfGW4=
|
||||
github.com/pion/dtls/v2 v2.2.11 h1:9U/dpCYl1ySttROPWJgqWKEylUdT0fXp/xst6JwY5Ks=
|
||||
github.com/pion/dtls/v2 v2.2.11/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||
github.com/pion/ice/v2 v2.3.25 h1:M5rJA07dqhi3nobJIg+uPtcVjFECTrhcR3n0ns8kDZs=
|
||||
github.com/pion/ice/v2 v2.3.25/go.mod h1:KXJJcZK7E8WzrBEYnV4UtqEZsGeWfHxsNqhVcVvgjxw=
|
||||
github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=
|
||||
github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
||||
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
|
||||
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtp v1.8.6 h1:MTmn/b0aWWsAzux2AmP8WGllusBVw4NPYPVFFd7jUPw=
|
||||
github.com/pion/rtp v1.8.6/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/sctp v1.8.16 h1:PKrMs+o9EMLRvFfXq59WFsC+V8mN1wnKzqrv+3D/gYY=
|
||||
github.com/pion/sctp v1.8.16/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE=
|
||||
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
|
||||
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
|
||||
github.com/pion/srtp/v2 v2.0.18 h1:vKpAXfawO9RtTRKZJbG4y0v1b11NZxQnxRl85kGuUlo=
|
||||
github.com/pion/srtp/v2 v2.0.18/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/transport/v2 v2.2.5 h1:iyi25i/21gQck4hfRhomF6SktmUQjRsRW4WJdhfc3Kc=
|
||||
github.com/pion/transport/v2 v2.2.5/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/webrtc/v3 v3.2.42 h1:WN/ZuMjtpQOoGRCZUg/zFG+JHEvYLVyDKOxU6H1qWlE=
|
||||
github.com/pion/webrtc/v3 v3.2.42/go.mod h1:M1RAe3TNTD1tzyvqHrbVODfwdPGSXOUo/OgpoGGJqFY=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/polydawn/refmt v0.89.0 h1:ADJTApkvkeBZsN0tBTx8QjpD9JkmxbKp0cxfr9qszm4=
|
||||
github.com/polydawn/refmt v0.89.0/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
|
||||
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
|
||||
github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
|
||||
github.com/quic-go/webtransport-go v0.8.0 h1:HxSrwun11U+LlmwpgM1kEqIqH90IT4N8auv/cD7QFJg=
|
||||
github.com/quic-go/webtransport-go v0.8.0/go.mod h1:N99tjprW432Ut5ONql/aUhSLT0YVSlwHohQsuac9WaM=
|
||||
github.com/raulk/go-watchdog v1.3.0 h1:oUmdlHxdkXRJlwfG0O9omj8ukerm8MEQavSiDTEtBsk=
|
||||
github.com/raulk/go-watchdog v1.3.0/go.mod h1:fIvOnLbF0b0ZwkB9YU4mOW9Did//4vPZtDqv66NfsMU=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/siderolabs/discovery-api v0.1.4 h1:2fMEFSMiWaD1zDiBDY5md8VxItvL1rDQRSOfeXNjYKc=
|
||||
github.com/siderolabs/discovery-api v0.1.4/go.mod h1:kaBy+G42v2xd/uAF/NIe383sjNTBE2AhxPTyi9SZI0s=
|
||||
github.com/siderolabs/discovery-client v0.1.9 h1:yDzvts++Nf/2qczdDUfU5GAibkEIgz/eo9RPG/k/rOc=
|
||||
github.com/siderolabs/discovery-client v0.1.9/go.mod h1:Ew1z07eyJwqNwum84IKYH4S649KEKK5WUmRW49HlXS8=
|
||||
github.com/siderolabs/gen v0.4.8 h1:VNpbmDLhkXp7qcSEkKk1Ee7vU2afs3xvHrWLGR2UuiY=
|
||||
github.com/siderolabs/gen v0.4.8/go.mod h1:7ROKVHHB68R3Amrd4a1ZXz/oMrXWF3Mg3lSEgnkJY5c=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
|
||||
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
|
||||
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s=
|
||||
github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y=
|
||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
|
||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc=
|
||||
go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||
go.uber.org/fx v1.22.1 h1:nvvln7mwyT5s1q201YE29V/BFrGor6vMiDNpU/78Mys=
|
||||
go.uber.org/fx v1.22.1/go.mod h1:HT2M7d7RHo+ebKGh9NRcrsrHHfpZ60nW3QRubMRfv48=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
|
||||
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
||||
+14
-13
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/ipfs/go-log/v2"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/ipfs/go-log/v2"
|
||||
)
|
||||
|
||||
// ipfsLogger is an slog logger that implements the IPFS go-log StandardLogger interface.
|
||||
@@ -16,57 +17,57 @@ func newIPFSLogger(l *slog.Logger) *ipfsLogger {
|
||||
return &ipfsLogger{log: *l}
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Debug(args ...interface{}) {
|
||||
func (l *ipfsLogger) Debug(args ...any) {
|
||||
l.log.Debug(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Debugf(format string, args ...interface{}) {
|
||||
func (l *ipfsLogger) Debugf(format string, args ...any) {
|
||||
l.log.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Error(args ...interface{}) {
|
||||
func (l *ipfsLogger) Error(args ...any) {
|
||||
l.log.Error(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Errorf(format string, args ...interface{}) {
|
||||
func (l *ipfsLogger) Errorf(format string, args ...any) {
|
||||
l.log.Error(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Fatal(args ...interface{}) {
|
||||
func (l *ipfsLogger) Fatal(args ...any) {
|
||||
l.log.Error(fmt.Sprint(args...))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Fatalf(format string, args ...interface{}) {
|
||||
func (l *ipfsLogger) Fatalf(format string, args ...any) {
|
||||
l.log.Error(fmt.Sprintf(format, args...))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Info(args ...interface{}) {
|
||||
func (l *ipfsLogger) Info(args ...any) {
|
||||
l.log.Info(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Infof(format string, args ...interface{}) {
|
||||
func (l *ipfsLogger) Infof(format string, args ...any) {
|
||||
l.log.Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Panic(args ...interface{}) {
|
||||
func (l *ipfsLogger) Panic(args ...any) {
|
||||
msg := fmt.Sprint(args...)
|
||||
l.log.Error(msg)
|
||||
panic(msg)
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Panicf(format string, args ...interface{}) {
|
||||
func (l *ipfsLogger) Panicf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
l.log.Error(msg)
|
||||
panic(msg)
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Warn(args ...interface{}) {
|
||||
func (l *ipfsLogger) Warn(args ...any) {
|
||||
l.log.Warn(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
func (l *ipfsLogger) Warnf(format string, args ...interface{}) {
|
||||
func (l *ipfsLogger) Warnf(format string, args ...any) {
|
||||
l.log.Warn(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
module github.com/psviderski/uncloud/experiment/netbird-load
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require github.com/netbirdio/netbird v0.71.0
|
||||
|
||||
require (
|
||||
cunicu.li/go-rosenpass v0.4.0 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
|
||||
github.com/aws/smithy-go v1.23.0 // indirect
|
||||
github.com/caddyserver/certmagic v0.21.3 // indirect
|
||||
github.com/caddyserver/zerossl v0.1.3 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cilium/ebpf v0.15.0 // indirect
|
||||
github.com/cloudflare/circl v1.3.3 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/coreos/go-iptables v0.7.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gliderlabs/ssh v0.3.8 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/google/nftables v0.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gopacket/gopacket v1.1.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/huin/goupnp v1.2.0 // indirect
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/koron/go-ssdp v0.0.4 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/libdns/libdns v0.2.2 // indirect
|
||||
github.com/libdns/route53 v1.5.0 // indirect
|
||||
github.com/libp2p/go-nat v0.2.0 // indirect
|
||||
github.com/libp2p/go-netroute v0.4.0 // indirect
|
||||
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/mholt/acmez/v2 v2.0.1 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.9 // indirect
|
||||
github.com/pion/ice/v4 v4.0.0-00010101000000-000000000000 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/stun/v2 v2.0.0 // indirect
|
||||
github.com/pion/stun/v3 v3.1.0 // indirect
|
||||
github.com/pion/transport/v2 v2.2.4 // indirect
|
||||
github.com/pion/transport/v3 v3.1.1 // indirect
|
||||
github.com/pion/turn/v3 v3.0.1 // indirect
|
||||
github.com/pion/turn/v4 v4.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pkg/sftp v1.13.9 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.4 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.2.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
|
||||
github.com/things-go/go-socks5 v0.0.4 // indirect
|
||||
github.com/ti-mo/conntrack v0.5.1 // indirect
|
||||
github.com/ti-mo/netfilter v0.5.2 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zcalusic/sysinfo v1.1.3 // indirect
|
||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1 // indirect
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 // indirect
|
||||
)
|
||||
|
||||
// Mirror the replace directives from netbird's own go.mod.
|
||||
// See https://github.com/netbirdio/netbird/blob/v0.71.0/go.mod.
|
||||
replace (
|
||||
github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6
|
||||
github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1
|
||||
github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
|
||||
github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949
|
||||
github.com/kardianos/service => github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502
|
||||
github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
||||
github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51
|
||||
golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user