mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
487
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f280003d87 | ||
|
|
531cebfff0 | ||
|
|
15037f382a | ||
|
|
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 |
@@ -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: 525 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
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
/uncloud
|
||||||
|
/uncloud-*
|
||||||
|
/uncloudd
|
||||||
|
/uncloudd-*
|
||||||
|
|
||||||
# OS X
|
# OS X
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -30,9 +34,13 @@ go.work.sum
|
|||||||
/data/
|
/data/
|
||||||
/dist/
|
/dist/
|
||||||
/uc
|
/uc
|
||||||
|
/tmp
|
||||||
|
|
||||||
# Web
|
# Web
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# VS Code
|
# VS Code
|
||||||
.vscode
|
.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
|
||||||
|
|
||||||
+2
-1
@@ -16,7 +16,8 @@ builds:
|
|||||||
main: ./cmd/uncloud
|
main: ./cmd/uncloud
|
||||||
binary: uncloud
|
binary: uncloud
|
||||||
env:
|
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:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- linux
|
- linux
|
||||||
|
|||||||
@@ -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,265 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
- `uncloud/`: CLI tool with subcommands for machine, service, volume management
|
||||||
|
- `uncloudd/`: Daemon that runs on each machine
|
||||||
|
- `ucind/`: Development cluster management for testing
|
||||||
|
|
||||||
|
- **`internal/`**: Internal implementation packages
|
||||||
|
|
||||||
|
- `cli/`: Command-line interface logic
|
||||||
|
- `machine/`: Machine lifecycle and state management
|
||||||
|
- `daemon/`: Daemon implementation and gRPC services
|
||||||
|
- `dns/`: Internal DNS server for service discovery
|
||||||
|
|
||||||
|
- **`pkg/`**: Public API packages for external use
|
||||||
|
|
||||||
|
- `api/`: Core API types and definitions
|
||||||
|
- `client/`: Client libraries for interacting with Uncloud
|
||||||
|
|
||||||
|
- **`experiment/`**: Experimental features and prototypes
|
||||||
|
- **`scripts/`**: Installation and utility scripts
|
||||||
|
- **`test/`**: Test suites and test infrastructure
|
||||||
|
- **`website/`**: Documentation website (Docusaurus)
|
||||||
|
|
||||||
|
- `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 uncloud ./cmd/uncloud
|
||||||
|
go build -o uncloudd ./cmd/uncloudd
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Make Targets
|
||||||
|
|
||||||
|
- `proto`: Generate protobuf code
|
||||||
|
- `ucind-cluster`: Create development cluster
|
||||||
|
- `update-dev`: Deploy to development machines
|
||||||
|
- `demo-reset`: Reset demo environment
|
||||||
|
- `fmt`: Format code
|
||||||
|
- `test`: Run all tests
|
||||||
|
- `lint`: Lint the code using golangci-lint
|
||||||
|
- `lint-and-fix`: Lint the code and fix issues whenever possible
|
||||||
|
|
||||||
|
## CLI Commands Structure
|
||||||
|
|
||||||
|
The `uc` CLI provides these main command groups:
|
||||||
|
|
||||||
|
### Machine Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc machine init <user@host> # Initialize new cluster
|
||||||
|
uc machine add <user@host> # Add machine to cluster
|
||||||
|
uc machine ls # List machines
|
||||||
|
uc machine rm <name> # Remove machine
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc run <image> # Run container from image
|
||||||
|
uc deploy # Deploy from compose.yaml
|
||||||
|
uc scale <service> <count> # Scale service replicas
|
||||||
|
uc ls # List services
|
||||||
|
uc rm <service> # Remove service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context and Connectivity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc context ls # List available contexts
|
||||||
|
uc context use <name> # Switch context
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Flags
|
||||||
|
|
||||||
|
- `--connect`: Connect to remote machine directly, without a config file
|
||||||
|
- `--uncloud-config`: Override config file path
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
|
||||||
|
- **Package naming**: Use clear, descriptive names
|
||||||
|
- **Error handling**: Wrap errors with context using `fmt.Errorf`
|
||||||
|
- **Logging**: Use structured logging with levels
|
||||||
|
- **gRPC**: Services defined in `internal/machine/api/pb/`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Prefer standard library when possible
|
||||||
|
- Pin versions in `go.mod`
|
||||||
|
- Document rationale for external dependencies
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- Support environment variables for key settings
|
||||||
|
- Validate configuration early
|
||||||
|
- Provide sensible defaults
|
||||||
|
|
||||||
|
## Troubleshooting and Debugging
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
- **Networking**: Check WireGuard status, iptables rules
|
||||||
|
- **DNS**: Verify service discovery resolution
|
||||||
|
- **Containers**: Use standard Docker debugging tools
|
||||||
|
- **State sync**: Check Corrosion logs for replication issues
|
||||||
|
|
||||||
|
### Debugging Tools
|
||||||
|
|
||||||
|
- Standard Linux networking tools (`ping`, `traceroute`, `wireshark`)
|
||||||
|
- Docker commands (`docker ps`, `docker logs`)
|
||||||
|
- SSH access to machines for direct inspection
|
||||||
|
- gRPC debugging tools
|
||||||
|
|
||||||
|
### Logs and Monitoring
|
||||||
|
|
||||||
|
- Systemd services (getting logs via `journalctl -u SERVICE_NAME`)
|
||||||
|
- `uncloud` -- Uncloud daemon
|
||||||
|
- `uncloud-corrosion` -- Corrosion process
|
||||||
|
- Machine daemon logs
|
||||||
|
- Container logs via Docker
|
||||||
|
|
||||||
|
## File Patterns and Conventions
|
||||||
|
|
||||||
|
### Important Files to Understand
|
||||||
|
|
||||||
|
- `cmd/uncloud/main.go`: CLI entry point and command structure
|
||||||
|
- `internal/cli/cli.go`: CLI implementation and configuration
|
||||||
|
- `internal/machine/machine.go`: Core machine management
|
||||||
|
- `pkg/api/`: Public API definitions
|
||||||
|
- `misc/design.md`: Architecture and design philosophy
|
||||||
|
- `README.md`: 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."
|
||||||
+18
-8
@@ -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 TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@@ -13,27 +13,37 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download && go mod verify
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
COPY . .
|
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
|
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
||||||
RUN wget -q -O /tmp/corrosion.tar.gz \
|
ARG TARGETARCH
|
||||||
https://github.com/psviderski/corrosion/releases/latest/download/corrosion-aarch64-unknown-linux-gnu.tar.gz \
|
|
||||||
|
RUN CORROSION_ARCH=$(case "${TARGETARCH}" in \
|
||||||
|
"amd64") echo "x86_64" ;; \
|
||||||
|
"arm64") echo "aarch64" ;; \
|
||||||
|
*) echo "Architecture '${TARGETARCH}' not supported" >&2 && exit 1 ;; \
|
||||||
|
esac) \
|
||||||
|
&& wget -q -O /tmp/corrosion.tar.gz \
|
||||||
|
"https://github.com/psviderski/corrosion/releases/latest/download/corrosion-${CORROSION_ARCH}-unknown-linux-gnu.tar.gz" \
|
||||||
&& tar -xzf /tmp/corrosion.tar.gz -C /tmp \
|
&& tar -xzf /tmp/corrosion.tar.gz -C /tmp \
|
||||||
&& install /tmp/corrosion /usr/local/bin/corrosion \
|
&& install /tmp/corrosion /usr/local/bin/corrosion \
|
||||||
&& rm /tmp/corrosion.tar.gz /tmp/corrosion
|
&& rm /tmp/corrosion.tar.gz /tmp/corrosion
|
||||||
|
|
||||||
FROM chainguard/wolfi-base:latest AS corrosion
|
# Beware that more modern images like chainguard/wolfi-base build glibc with flags that require newer CPU features,
|
||||||
|
# e.g. x86-64-v2. So such image may fail with "Fatal glibc error: CPU does not support x86-64-v2" on older CPUs.
|
||||||
|
FROM gcr.io/distroless/cc-debian12:latest AS corrosion
|
||||||
COPY --from=corrosion-download /usr/local/bin/corrosion /usr/local/bin/corrosion
|
COPY --from=corrosion-download /usr/local/bin/corrosion /usr/local/bin/corrosion
|
||||||
CMD ["corrosion", "agent"]
|
CMD ["corrosion", "agent"]
|
||||||
|
|
||||||
|
|
||||||
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
|
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
|
||||||
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
|
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
RUN apk --no-cache add crane
|
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.
|
# Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker.
|
||||||
FROM docker:27.3.1-dind AS ucind
|
FROM docker:27.3.1-dind AS ucind
|
||||||
|
|||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
# 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/) (for running end-to-end tests)
|
||||||
|
|
||||||
|
## 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/uncloud
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build and run the CLI with a single command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
go run ./cmd/uncloud --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,62 +1,73 @@
|
|||||||
|
# TODO: Makefile is deprecated, add new targets as mise tasks in mise.toml instead.
|
||||||
|
|
||||||
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
|
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
|
||||||
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
|
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
|
|
||||||
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
|
|
||||||
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
|
|
||||||
# ssh ubuntu@152.67.101.197 sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && \
|
|
||||||
# rm uncloudd-linux-arm64
|
|
||||||
|
|
||||||
update-restart-dev:
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
|
|
||||||
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
|
|
||||||
ssh spy@192.168.40.243 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
|
|
||||||
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
|
|
||||||
ssh spy@192.168.40.176 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
|
|
||||||
rm uncloudd-linux-amd64
|
|
||||||
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
|
|
||||||
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
|
|
||||||
# ssh ubuntu@152.67.101.197 "sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
|
|
||||||
# rm uncloudd-linux-arm64
|
|
||||||
|
|
||||||
reset-dev:
|
|
||||||
ssh spy@192.168.40.243 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
|
||||||
ssh spy@192.168.40.176 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
|
||||||
ssh ubuntu@152.67.101.197 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
|
||||||
|
|
||||||
demo-reset:
|
|
||||||
rm -fv ~/.config/uncloud/config.yaml
|
|
||||||
ssh ubuntu@152.67.101.197 "AUTO_CONFIRM=true sudo -E uncloud-uninstall && docker rmi caddy:2.9.1"
|
|
||||||
ssh root@5.223.45.199 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
|
||||||
ssh spy@192.168.40.243 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
|
||||||
|
|
||||||
.PHONY: ucind-cluster
|
.PHONY: ucind-cluster
|
||||||
ucind-cluster:
|
ucind-cluster:
|
||||||
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
|
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
|
||||||
|
|
||||||
.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: corrosion-image
|
.PHONY: corrosion-image
|
||||||
corrosion-image:
|
corrosion-image:
|
||||||
docker build -t "$(CORROSION_IMAGE)" --target corrosion .
|
docker build -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||||
|
|
||||||
.PHONY: corrosion-image-push
|
.PHONY: corrosion-multiarch-image-push
|
||||||
corrosion-image-push: corrosion-image
|
corrosion-multiarch-image-push:
|
||||||
docker push "$(CORROSION_IMAGE)"
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||||
|
|
||||||
.PHONY: ucind-image
|
.PHONY: ucind-multiarch-image-push
|
||||||
ucind-image:
|
ucind-multiarch-image-push:
|
||||||
docker build -t "$(UCIND_IMAGE)" --target ucind .
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
||||||
|
|
||||||
.PHONY: ucind-image-push
|
.PHONY: mocks
|
||||||
ucind-image-push: ucind-image
|
mocks:
|
||||||
docker push "$(UCIND_IMAGE)"
|
@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/uncloud docs
|
||||||
|
|||||||
@@ -1,54 +1,74 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./website/images/logo.svg" height="100" alt="Uncloud logo"/>
|
<img src="./website/landing/images/logo-title.svg#gh-light-mode-only" alt="Uncloud logo"/>
|
||||||
<h1>Uncloud</h1>
|
<img src="./website/landing/images/logo-title-dark.svg#gh-dark-mode-only" alt="Uncloud logo"/>
|
||||||
<p><strong>Docker simplicity. Multi-machine power.</strong></p>
|
<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>
|
</div>
|
||||||
|
|
||||||
Uncloud is a lightweight clustering and container orchestration tool that lets you deploy and manage web apps across
|
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
|
your Docker hosts and provides automatic service discovery, load balancing, ingress with HTTPS, and simple CLI commands
|
||||||
to manage your apps.
|
to manage your apps.
|
||||||
|
|
||||||
Unlike traditional orchestrators, there's no central control plane and quorum to maintain. Each machine maintains a
|
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.
|
some machines go offline.
|
||||||
|
|
||||||
Uncloud is the solution for developers who want the flexibility of self-hosted infrastructure without the
|
Uncloud is the solution for developers who want the flexibility of self-hosted infrastructure without the operational
|
||||||
operational complexity of Kubernetes.
|
complexity of Kubernetes.
|
||||||
|
|
||||||
## 🎬 Quick demo
|
|
||||||
|
|
||||||
Deploy a highly available web app with automatic HTTPS across multiple regions and on-premises in just a couple minutes.
|
|
||||||
|
|
||||||
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
|
|
||||||
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ 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.
|
regardless of location or provider.
|
||||||
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
|
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
|
||||||
need to learn a new bespoke DSL.
|
need to learn a new bespoke DSL.
|
||||||
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
|
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
|
||||||
soon.
|
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.
|
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
||||||
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
|
* **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
|
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
|
||||||
IPs for direct cross-machine communication.
|
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
|
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
||||||
model and troubleshooting.
|
model and troubleshooting.
|
||||||
* **Managed DNS**: Automatic DNS records `*.<id>.cluster.uncloud.run` for services with public access via managed
|
* **Managed DNS**: Automatic DNS records `*.xxxxxx.uncld.dev` for services with public access via managed
|
||||||
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
[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
|
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
||||||
Encrypt.
|
Encrypt.
|
||||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
* **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.
|
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
||||||
|
|
||||||
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
## 🎬 Quick demo
|
||||||
|
|
||||||

|
The screenshot below demonstrates how I use Uncloud to deploy https://uncloud.run website to 2 remote machines from
|
||||||
|
the [`compose.yaml`](website/compose.yaml) file on my local machine.
|
||||||
|
|
||||||
|
It exposes the container port `8000/tcp` as HTTPS on the domain `uncloud.run`, served by the Caddy reverse proxy on the
|
||||||
|
remote machines. All managed by Uncloud.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Here is a more advanced use case. Deploy a highly available web app with automatic HTTPS across multiple regions and
|
||||||
|
on-premises in just a couple minutes.
|
||||||
|
|
||||||
|
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
|
||||||
|
<img src="https://embed-ssl.wistia.com/deliveries/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?
|
## 💫 Why Uncloud?
|
||||||
|
|
||||||
@@ -58,14 +78,15 @@ believe there's a sweet spot in between — a pragmatic solution for the majorit
|
|||||||
scale. You should be able to:
|
scale. You should be able to:
|
||||||
|
|
||||||
* **Own your infrastructure and data**: Whether driven by costs, compliance, or flexibility, run applications on any
|
* **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.
|
combination of cloud VMs and personal hardware while controlling your data and maintaining the cloud-like experience
|
||||||
* **Stay simple**: Don't worry about control planes, highly-available clusters, or complex YAML configurations for
|
you love.
|
||||||
common use cases.
|
* **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
|
* **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.
|
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.
|
platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bare metal servers.
|
||||||
|
|
||||||
## 🚀 Quick start
|
## 🚀 Quick start
|
||||||
@@ -79,7 +100,9 @@ platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bar
|
|||||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Initialize your first machine:
|
See [Installation](https://uncloud.run/docs/getting-started/install-cli) for more options.
|
||||||
|
|
||||||
|
2. Initialise your first machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc machine init root@your-server-ip
|
uc machine init root@your-server-ip
|
||||||
@@ -110,16 +133,20 @@ platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bar
|
|||||||
uncloud-uninstall
|
uncloud-uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
View the [user guide](docs/user_guide.md) for more information.
|
View the [Documentation](https://uncloud.run/docs) for more information.
|
||||||
|
|
||||||
## ⚙️ How it works
|
## ⚙️ How it works
|
||||||
|
|
||||||
Check out the [design document](docs/design.md) to understand Uncloud's design philosophy and goals.
|
Check out the [design document](misc/design.md) to understand Uncloud's design philosophy and goals.
|
||||||
|
|
||||||
|
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Here, let's peek under the hood to see what happens when you run certain commands.</summary>
|
<summary>Peek under the hood to see what happens when you run certain commands.</summary>
|
||||||
|
|
||||||
**When you initialize a new cluster on a machine:**
|
**When you initialise a new cluster on a machine:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uc machine init --name oracle-vm ubuntu@152.67.101.197
|
$ uc machine init --name oracle-vm ubuntu@152.67.101.197
|
||||||
@@ -254,25 +281,76 @@ using the CLI or directly over SSH. They all have the complete cluster state and
|
|||||||
machine is a full backup of your control plane.
|
machine is a full backup of your control plane.
|
||||||
</details>
|
</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.
|
||||||
|
|
||||||
|
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
|
## 🏗 Project status
|
||||||
|
|
||||||
Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
|
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.
|
and there may be breaking changes between releases.
|
||||||
|
|
||||||
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)
|
* 🐛 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
|
## 📫 Stay updated
|
||||||
|
|
||||||
* [Subscribe](https://uncloud.run/#subscribe) to my newsletter to follow the progress, get early insights into new
|
* Join our [Discord server](https://discord.gg/eR35KQJhPu) for real-time discussions, support, and updates.
|
||||||
|
* Follow [@psviderski](https://x.com/psviderski) on X/Twitter.
|
||||||
|
* Subscribe to [my newsletter](https://uncloud.run/#subscribe) to follow the progress, get early insights into new
|
||||||
features, and be the first to know when it's ready for production use.
|
features, and be the first to know when it's ready for production use.
|
||||||
* Watch this repository for releases.
|
* Watch this repository for releases.
|
||||||
* Follow [@psviderski](https://x.com/psviderski) on X/Twitter.
|
|
||||||
|
## 💖 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
|
## ❤️ Contributors
|
||||||
|
|
||||||
|
<a href="https://trendshift.io/repositories/14069">
|
||||||
|
<img alt="Trendshift" src="https://trendshift.io/api/badge/repositories/14069" />
|
||||||
|
</a>
|
||||||
|
|
||||||
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
|
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
|
||||||
|
|
||||||
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
|
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/psviderski/uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewCreateCommand() *cobra.Command {
|
func NewCreateCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/psviderski/uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRemoveCommand() *cobra.Command {
|
func NewRemoveCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewCreateCommand(),
|
NewCreateCommand(),
|
||||||
//NewListCommand(),
|
// NewListCommand(),
|
||||||
NewRemoveCommand(),
|
NewRemoveCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
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/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.")
|
||||||
|
|
||||||
|
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, _, err = clusterClient.ProxyMachinesContext(ctx, []string{opts.machine})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := clusterClient.Caddy.GetConfig(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get Caddy config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the Caddyfile with syntax highlighting.
|
||||||
|
if opts.noColor {
|
||||||
|
fmt.Print(config.Caddyfile)
|
||||||
|
} else {
|
||||||
|
if err = quick.Highlight(os.Stdout, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
|
||||||
|
// If highlighting fails, fall back to plain output.
|
||||||
|
fmt.Print(config.Caddyfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+66
-26
@@ -5,12 +5,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/docker/cli/cli/streams"
|
"github.com/docker/cli/cli/streams"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
@@ -18,9 +21,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type deployOptions struct {
|
type deployOptions struct {
|
||||||
image string
|
caddyfile string
|
||||||
machines []string
|
image string
|
||||||
context string
|
machines []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeployCommand() *cobra.Command {
|
func NewDeployCommand() *cobra.Command {
|
||||||
@@ -37,21 +40,28 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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", "",
|
cmd.Flags().StringVar(&opts.image, "image", "",
|
||||||
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
||||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
"Machine names to deploy to. Can be specified multiple times or as a comma-separated "+
|
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||||
"list of machine names. (default is all machines)")
|
"list. (default is all machines)")
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context to deploy to. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -62,9 +72,11 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
if !errors.Is(err, api.ErrNotFound) {
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
return fmt.Errorf("inspect caddy service: %w", err)
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName)
|
fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(client.CaddyServiceName) +
|
||||||
|
tui.Faint.Render(" (not running)"))
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Service: %s (%s mode)\n", svc.Name, svc.Mode)
|
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.
|
// Collect unique images of all containers in the running caddy service.
|
||||||
images := make(map[string]struct{}, len(svc.Containers))
|
images := make(map[string]struct{}, len(svc.Containers))
|
||||||
@@ -74,15 +86,19 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
currentImages := slices.Collect(maps.Keys(images))
|
currentImages := slices.Collect(maps.Keys(images))
|
||||||
|
|
||||||
if len(currentImages) > 1 {
|
if len(currentImages) > 1 {
|
||||||
commaSeparatedImages := strings.Join(currentImages, ", ")
|
formattedImages := make([]string, len(currentImages))
|
||||||
fmt.Printf("Current images (multiple versions detected): %s\n", commaSeparatedImages)
|
for i, img := range currentImages {
|
||||||
|
formattedImages[i] = tui.FormatImage(img, lipgloss.NewStyle())
|
||||||
|
}
|
||||||
|
fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") +
|
||||||
|
strings.Join(formattedImages, tui.Faint.Render(", ")))
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Current image: %s\n", currentImages[0])
|
fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], lipgloss.NewStyle()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.image != "" {
|
if opts.image != "" {
|
||||||
fmt.Printf("Target image: %s\n", opts.image)
|
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
@@ -91,13 +107,14 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
placement := api.Placement{
|
placement := api.Placement{
|
||||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||||
}
|
}
|
||||||
d, err := clusterClient.NewCaddyDeployment(opts.image, placement)
|
d, err := clusterClient.NewCaddyDeployment(opts.image, caddyfile, placement)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.image == "" {
|
if opts.image == "" {
|
||||||
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.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)
|
plan, err := d.Plan(ctx)
|
||||||
@@ -116,18 +133,41 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialise a machine and container name resolver to properly format the plan output.
|
fmt.Println()
|
||||||
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
|
||||||
if err != nil {
|
fmt.Println()
|
||||||
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
|
||||||
|
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()
|
fmt.Println(plan.Format())
|
||||||
fmt.Println("Deployment plan:")
|
|
||||||
fmt.Println(plan.Format(resolver))
|
summary := plan.FormatSummary()
|
||||||
|
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
|
||||||
|
fmt.Println(summary)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
confirmed, err := cli.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 {
|
if err != nil {
|
||||||
return fmt.Errorf("confirm deployment: %w", err)
|
return fmt.Errorf("confirm deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Short: "Manage Caddy reverse proxy service.",
|
Short: "Manage Caddy reverse proxy service.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
NewConfigCommand(),
|
||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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.",
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -12,8 +12,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
var clusterContext string
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
@@ -24,11 +22,6 @@ func NewListCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&clusterContext, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +44,7 @@ func list(uncli *cli.CLI) error {
|
|||||||
for _, name := range contextNames {
|
for _, name := range contextNames {
|
||||||
current := ""
|
current := ""
|
||||||
if name == currentContext {
|
if name == currentContext {
|
||||||
current = "*"
|
current = "✓"
|
||||||
}
|
}
|
||||||
connCount := len(uncli.Config.Contexts[name].Connections)
|
connCount := len(uncli.Config.Contexts[name].Connections)
|
||||||
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewListCommand(),
|
NewListCommand(),
|
||||||
NewUseCommand(),
|
NewUseCommand(),
|
||||||
|
NewConnectionCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"charm.land/huh/v2"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
+147
-74
@@ -4,12 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"os"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
@@ -17,11 +18,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type deployOptions struct {
|
type deployOptions struct {
|
||||||
files []string
|
cli.BuildServicesOptions
|
||||||
profiles []string
|
|
||||||
services []string
|
|
||||||
|
|
||||||
context string
|
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.
|
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
||||||
@@ -31,48 +36,54 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
Use: "deploy [FLAGS] [SERVICE...]",
|
Use: "deploy [FLAGS] [SERVICE...]",
|
||||||
Short: "Deploy services from a Compose file.",
|
Short: "Deploy services from a Compose file.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||||
|
|
||||||
if len(args) > 0 {
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
opts.services = args
|
opts.services = args
|
||||||
}
|
|
||||||
|
|
||||||
return runDeploy(cmd.Context(), uncli, opts)
|
return runDeploy(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: "service",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||||
"One or more Compose files to deploy services from. (default compose.yaml)")
|
"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,
|
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||||
"One or more Compose profiles to enable.")
|
"One or more Compose profiles to enable.")
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
||||||
"Name of the cluster context to deploy to (default is the current context)")
|
"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.
|
// 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.
|
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// projectOpts returns the project options for the Compose file(s).
|
|
||||||
func projectOpts(opts deployOptions) []composecli.ProjectOptionsFn {
|
|
||||||
projectOpts := []composecli.ProjectOptionsFn{}
|
|
||||||
|
|
||||||
if len(opts.profiles) > 0 {
|
|
||||||
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.profiles...))
|
|
||||||
}
|
|
||||||
|
|
||||||
return projectOpts
|
|
||||||
}
|
|
||||||
|
|
||||||
// runDeploy parses the Compose file(s) and deploys the services.
|
// runDeploy parses the Compose file(s) and deploys the services.
|
||||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
projectOpts := projectOpts(opts)
|
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
|
||||||
|
|
||||||
project, err := compose.LoadProject(ctx, opts.files, projectOpts...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load compose file(s): %w", err)
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
|
||||||
|
|
||||||
if len(opts.services) > 0 {
|
if len(opts.services) > 0 {
|
||||||
// Includes service dependencies by default. This is the default docker compose behavior.
|
// Includes service dependencies by default. This is the default docker compose behavior.
|
||||||
project, err = project.WithSelectedServices(opts.services)
|
project, err = project.WithSelectedServices(opts.services)
|
||||||
@@ -81,13 +92,74 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
defer clusterClient.Close()
|
defer clusterClient.Close()
|
||||||
|
|
||||||
composeDeploy, err := compose.NewDeployment(ctx, clusterClient, project)
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy := &deploy.RollingStrategy{
|
||||||
|
ForceRecreate: opts.recreate,
|
||||||
|
SkipHealthMonitor: opts.skipHealth,
|
||||||
|
}
|
||||||
|
composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create compose deployment: %w", err)
|
return fmt.Errorf("create compose deployment: %w", err)
|
||||||
}
|
}
|
||||||
@@ -97,63 +169,64 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
return fmt.Errorf("plan deployment: %w", err)
|
return fmt.Errorf("plan deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(plan.Operations) == 0 {
|
if plan.IsEmpty() {
|
||||||
fmt.Println("Services are up to date.")
|
fmt.Println("Services are up to date.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Deployment plan:")
|
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
|
||||||
if err = printPlan(ctx, clusterClient, plan); err != nil {
|
|
||||||
return fmt.Errorf("print deployment plan: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
confirmed, err := cli.Confirm()
|
directConn := uncli.DirectConnection()
|
||||||
if err != nil {
|
contextName := uncli.ContextOverrideOrCurrent()
|
||||||
return fmt.Errorf("confirm deployment: %w", err)
|
deployTarget := ""
|
||||||
}
|
if directConn != "" {
|
||||||
if !confirmed {
|
deployTarget = directConn
|
||||||
fmt.Println("Cancelled. No changes were made.")
|
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
|
||||||
return nil
|
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.IsStdinTerminal() {
|
||||||
|
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 {
|
||||||
|
fmt.Println("Cancelled. No changes were made.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := "Deploying"
|
||||||
|
if deployTarget != "" {
|
||||||
|
title += " to " + tui.NameStyle.Render(deployTarget)
|
||||||
|
}
|
||||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
if err := plan.Execute(ctx, clusterClient); err != nil {
|
if err := plan.Execute(ctx, clusterClient); err != nil {
|
||||||
return fmt.Errorf("deploy services: %w", err)
|
return fmt.Errorf("deploy services: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, uncli.ProgressOut(), "Deploying services")
|
}, uncli.ProgressOut(), title)
|
||||||
}
|
|
||||||
|
|
||||||
func printPlan(ctx context.Context, cli *client.Client, plan deploy.SequenceOperation) error {
|
|
||||||
for _, op := range plan.Operations {
|
|
||||||
svcPlan, ok := op.(*deploy.Plan)
|
|
||||||
if !ok {
|
|
||||||
fmt.Println("- " + op.Format(nil))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
svc, err := cli.InspectService(ctx, svcPlan.ServiceID)
|
|
||||||
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
|
||||||
return fmt.Errorf("inspect service: %w", err)
|
|
||||||
}
|
|
||||||
// Initialise a machine and container name resolver to properly format the service plan output.
|
|
||||||
resolver, err := cli.ServiceOperationNameResolver(ctx, svc)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("- Deploy service [name=%s]\n", svcPlan.ServiceName)
|
|
||||||
fmt.Println(indent(svcPlan.Format(resolver), " "))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func indent(text, prefix string) string {
|
|
||||||
lines := strings.Split(text, "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
lines[i] = prefix + line
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,32 +12,21 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
type releaseOptions struct {
|
|
||||||
context string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReleaseCommand() *cobra.Command {
|
func NewReleaseCommand() *cobra.Command {
|
||||||
opts := releaseOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "release",
|
Use: "release",
|
||||||
Short: "Release the reserved cluster domain.",
|
Short: "Release the reserved cluster domain.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return release(cmd.Context(), uncli, opts)
|
return release(cmd.Context(), uncli)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func release(ctx context.Context, uncli *cli.CLI, opts releaseOptions) error {
|
func release(ctx context.Context, uncli *cli.CLI) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
|
|||||||
|
|
||||||
type reserveOptions struct {
|
type reserveOptions struct {
|
||||||
endpoint string
|
endpoint string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReserveCommand() *cobra.Command {
|
func NewReserveCommand() *cobra.Command {
|
||||||
@@ -36,16 +35,12 @@ func NewReserveCommand() *cobra.Command {
|
|||||||
|
|
||||||
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
|
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
|
||||||
"API endpoint for the Uncloud DNS service.")
|
"API endpoint for the Uncloud DNS service.")
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Use: "dns",
|
Use: "dns",
|
||||||
Short: "Manage cluster domain in Uncloud DNS.",
|
Short: "Manage cluster domain in Uncloud DNS.",
|
||||||
Long: "Manage cluster domain in Uncloud DNS.\n" +
|
Long: "Manage cluster domain in Uncloud DNS.\n" +
|
||||||
"DNS commands allow you to reserve or release a unique '<id>.cluster.uncloud.run' domain for your " +
|
"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 " +
|
"cluster. When reserved, Caddy service deployments will automatically update DNS records to route " +
|
||||||
"traffic to the services in the cluster.",
|
"traffic to the services in the cluster.",
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-14
@@ -10,32 +10,21 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type showOptions struct {
|
|
||||||
context string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewShowCommand() *cobra.Command {
|
func NewShowCommand() *cobra.Command {
|
||||||
opts := showOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "show",
|
Use: "show",
|
||||||
Short: "Print the cluster domain name.",
|
Short: "Print the cluster domain name.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return show(cmd.Context(), uncli, opts)
|
return show(cmd.Context(), uncli)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
func show(ctx context.Context, uncli *cli.CLI) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/cobra/doc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const docsDir = "website/docs/9-cli-reference"
|
||||||
|
|
||||||
|
type cmdWrapper struct {
|
||||||
|
cmd *cobra.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDocsCommand creates a new hidden command to generate CLI reference docs.
|
||||||
|
func NewDocsCommand() *cobra.Command {
|
||||||
|
wrapper := &cmdWrapper{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "docs",
|
||||||
|
Short: "Generate Uncloud CLI reference docs",
|
||||||
|
SilenceUsage: true,
|
||||||
|
DisableFlagsInUseLine: true,
|
||||||
|
Hidden: true,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
ValidArgsFunction: cobra.NoFileCompletions,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
// Remove existing markdown files.
|
||||||
|
mdFiles, err := filepath.Glob(filepath.Join(docsDir, "*.md"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list existing CLI docs: %w", err)
|
||||||
|
}
|
||||||
|
for _, f := range mdFiles {
|
||||||
|
if err = os.Remove(f); err != nil {
|
||||||
|
return fmt.Errorf("remove '%s': %w", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new CLI reference docs.
|
||||||
|
wrapper.cmd.Root().DisableAutoGenTag = true
|
||||||
|
if err := doc.GenMarkdownTree(cmd.Root(), docsDir); err != nil {
|
||||||
|
return fmt.Errorf("generate CLI docs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove *completion*.md files that contain malformatted code blocks that break Docusaurus.
|
||||||
|
mdFiles, err = filepath.Glob(filepath.Join(docsDir, "*completion*.md"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list generated CLI docs: %w", err)
|
||||||
|
}
|
||||||
|
for _, f := range mdFiles {
|
||||||
|
if err = os.Remove(f); err != nil {
|
||||||
|
return fmt.Errorf("remove '%s': %w", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-process generated markdown files.
|
||||||
|
mdFiles, err = filepath.Glob(filepath.Join(docsDir, "*.md"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list generated CLI docs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range mdFiles {
|
||||||
|
if err = postProcessMarkdown(f); err != nil {
|
||||||
|
return fmt.Errorf("post-process '%s': %w", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.cmd = cmd
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// postProcessMarkdown applies transformations to generated markdown files.
|
||||||
|
func postProcessMarkdown(filename string) error {
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
// Replace "SEE ALSO" with "See also".
|
||||||
|
content = strings.ReplaceAll(content, "SEE ALSO", "See also")
|
||||||
|
// Escape <id> to avoid Docusaurus treating it as an HTML tag.
|
||||||
|
content = strings.ReplaceAll(content, "<id>", "\\<id>")
|
||||||
|
|
||||||
|
// Remove broken links to completion docs.
|
||||||
|
if strings.Contains(content, "[uc completion") {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
var filteredLines []string
|
||||||
|
for _, line := range lines {
|
||||||
|
if !strings.Contains(line, "[uc completion") {
|
||||||
|
filteredLines = append(filteredLines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = strings.Join(filteredLines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust heading levels. Process from shortest to longest to avoid double replacements.
|
||||||
|
replacements := []struct {
|
||||||
|
old, new string
|
||||||
|
}{
|
||||||
|
{`(?m)^## `, `# `},
|
||||||
|
{`(?m)^### `, `## `},
|
||||||
|
{`(?m)^#### `, `### `},
|
||||||
|
{`(?m)^##### `, `#### `},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range replacements {
|
||||||
|
re := regexp.MustCompile(r.old)
|
||||||
|
content = re.ReplaceAllString(content, r.new)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(filename, []byte(content), 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package image
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"charm.land/lipgloss/v2/table"
|
||||||
|
"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/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)")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Get all machines to create ID to name mapping.
|
||||||
|
allMachines, err := clusterClient.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
machineIDToName := make(map[string]string)
|
||||||
|
for _, machineMember := range allMachines {
|
||||||
|
if machineMember.Machine != nil && machineMember.Machine.Id != "" && machineMember.Machine.Name != "" {
|
||||||
|
machineIDToName[machineMember.Machine.Id] = machineMember.Machine.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Get machine name for better readability.
|
||||||
|
machineName := machineImages.Metadata.Machine
|
||||||
|
if m := allMachines.FindByNameOrID(machineName); m != nil {
|
||||||
|
machineName = m.Machine.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
fmt.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
|
||||||
|
}
|
||||||
|
|
||||||
|
t := table.New().
|
||||||
|
// Remove the default border.
|
||||||
|
Border(lipgloss.Border{}).
|
||||||
|
BorderTop(false).
|
||||||
|
BorderBottom(false).
|
||||||
|
BorderLeft(false).
|
||||||
|
BorderRight(false).
|
||||||
|
BorderHeader(false).
|
||||||
|
BorderColumn(false).
|
||||||
|
StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
|
if row == table.HeaderRow {
|
||||||
|
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
|
||||||
|
}
|
||||||
|
// Regular style for data rows with padding.
|
||||||
|
return lipgloss.NewStyle().PaddingRight(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
row.name,
|
||||||
|
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,94 @@
|
|||||||
|
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/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.",
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package image
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "image",
|
||||||
|
Short: "Manage images on machines in the cluster.",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
NewListCommand(),
|
||||||
|
NewPushCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/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
|
||||||
|
}
|
||||||
+152
-84
@@ -5,27 +5,31 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"charm.land/huh/v2/spinner"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"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/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type addOptions struct {
|
type addOptions struct {
|
||||||
name string
|
name string
|
||||||
noCaddy bool
|
noCaddy bool
|
||||||
publicIP string
|
noInstall bool
|
||||||
sshKey string
|
publicIP string
|
||||||
context string
|
sshKey string
|
||||||
version string
|
version string
|
||||||
|
wgEndpoints []string
|
||||||
|
yes bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAddCommand() *cobra.Command {
|
func NewAddCommand() *cobra.Command {
|
||||||
@@ -33,19 +37,33 @@ func NewAddCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add [USER@]HOST[:PORT]",
|
Use: "add [USER@]HOST[:PORT]",
|
||||||
Short: "Add a remote machine to a cluster.",
|
Short: "Add a remote machine to a cluster.",
|
||||||
Args: cobra.ExactArgs(1),
|
Long: `Add a new machine to an existing Uncloud cluster.
|
||||||
|
|
||||||
|
Connection methods:
|
||||||
|
ssh://user@host - Use built-in SSH library (default, no prefix required)
|
||||||
|
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||||
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
user, host, port, err := config.SSHDestination(args[0]).Parse()
|
// Determine if SSH CLI needs to be used and strip scheme
|
||||||
|
destination := args[0]
|
||||||
|
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
|
||||||
|
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
||||||
|
destination = strings.TrimPrefix(destination, "ssh://")
|
||||||
|
|
||||||
|
user, host, port, err := config.SSHDestination(destination).Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse remote machine: %w", err)
|
return fmt.Errorf("parse remote machine: %w", err)
|
||||||
}
|
}
|
||||||
remoteMachine := cli.RemoteMachine{
|
remoteMachine := &cli.RemoteMachine{
|
||||||
User: user,
|
User: user,
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
KeyPath: opts.sshKey,
|
KeyPath: opts.sshKey,
|
||||||
|
UseSSHCLI: useSSHCLI,
|
||||||
}
|
}
|
||||||
|
|
||||||
return add(cmd.Context(), uncli, remoteMachine, opts)
|
return add(cmd.Context(), uncli, remoteMachine, opts)
|
||||||
@@ -56,33 +74,46 @@ func NewAddCommand() *cobra.Command {
|
|||||||
&opts.noCaddy, "no-caddy", false,
|
&opts.noCaddy, "no-caddy", false,
|
||||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||||
)
|
)
|
||||||
|
cmd.Flags().BoolVar(
|
||||||
|
&opts.noInstall, "no-install", false,
|
||||||
|
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
||||||
|
"Assumes they're already installed and running.",
|
||||||
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.publicIP, "public-ip", "auto",
|
&opts.publicIP, "public-ip", "auto",
|
||||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||||
"blank '' or 'none' to disable ingress on this machine, or specify an IP address.",
|
fmt.Sprintf("blank '' or '%s' to disable ingress on this machine, or specify an IP address.", PublicIPNone),
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "",
|
||||||
"path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
fmt.Sprintf("Path to SSH private key for remote login (if not already added to SSH agent). (default %q)",
|
||||||
|
cli.DefaultSSHKeyPath),
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.version, "version", "latest",
|
&opts.version, "version", "latest",
|
||||||
"Version of the Uncloud daemon to install on the machine.",
|
"Version of the Uncloud daemon to install on the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringSliceVar(
|
||||||
&opts.context, "context", "c", "",
|
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||||
"Name of the cluster context to add the machine to. (default is the current context)",
|
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
||||||
|
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
||||||
|
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
||||||
|
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
||||||
|
"If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
|
||||||
)
|
)
|
||||||
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, opts addOptions) error {
|
func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts addOptions) error {
|
||||||
var publicIP *netip.Addr
|
var publicIP *netip.Addr
|
||||||
switch opts.publicIP {
|
switch opts.publicIP {
|
||||||
case "auto":
|
case "auto":
|
||||||
publicIP = &netip.Addr{}
|
publicIP = &netip.Addr{}
|
||||||
case "", "none":
|
case "", PublicIPNone:
|
||||||
publicIP = nil
|
publicIP = nil
|
||||||
default:
|
default:
|
||||||
ip, err := netip.ParseAddr(opts.publicIP)
|
ip, err := netip.ParseAddr(opts.publicIP)
|
||||||
@@ -92,92 +123,129 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
publicIP = &ip
|
publicIP = &ip
|
||||||
}
|
}
|
||||||
|
|
||||||
machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
addOpts := cli.AddMachineOptions{
|
||||||
Context: opts.context,
|
|
||||||
MachineName: opts.name,
|
MachineName: opts.name,
|
||||||
PublicIP: publicIP,
|
PublicIP: publicIP,
|
||||||
RemoteMachine: remoteMachine,
|
RemoteMachine: remoteMachine,
|
||||||
|
SkipInstall: opts.noInstall,
|
||||||
Version: opts.version,
|
Version: opts.version,
|
||||||
})
|
AutoConfirm: opts.yes,
|
||||||
|
}
|
||||||
|
if len(opts.wgEndpoints) > 0 {
|
||||||
|
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||||
|
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
||||||
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
defer machineClient.Close()
|
defer machineClient.Close()
|
||||||
|
|
||||||
if opts.noCaddy {
|
if opts.noCaddy {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the cluster to be initialised to be able to deploy the Caddy service.
|
// Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service.
|
||||||
fmt.Println("Waiting for the machine to be ready...")
|
err = spinner.New().
|
||||||
fmt.Println()
|
Title(" Waiting for the machine to join the cluster...").
|
||||||
if err = waitClusterInitialised(ctx, machineClient); err != nil {
|
Type(spinner.MiniDot).
|
||||||
return fmt.Errorf("wait for cluster to be initialised on machine: %w", err)
|
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
|
||||||
}
|
return &spinner.Styles{
|
||||||
|
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||||
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
Title: lipgloss.NewStyle(),
|
||||||
// use the deployed image version. Otherwise, use the latest version.
|
}
|
||||||
caddyImage := ""
|
})).
|
||||||
caddySvc, err := machineClient.InspectService(ctx, client.CaddyServiceName)
|
ActionWithErr(func(ctx context.Context) error {
|
||||||
|
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
|
||||||
|
}).
|
||||||
|
Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, api.ErrNotFound) {
|
return fmt.Errorf("wait for machine to join the cluster: %w", err)
|
||||||
return fmt.Errorf("inspect caddy service: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
caddyImage = caddySvc.Containers[0].Container.Config.Image
|
|
||||||
// Find the latest created container and use its image.
|
|
||||||
var latestCreated time.Time
|
|
||||||
for _, c := range caddySvc.Containers[1:] {
|
|
||||||
created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if created.After(latestCreated) {
|
|
||||||
latestCreated = created
|
|
||||||
caddyImage = c.Container.Config.Image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
fmt.Println("Machine joined the cluster.")
|
||||||
|
|
||||||
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||||
// that may cause a small downtime.
|
// that may cause a small downtime.
|
||||||
d, err := machineClient.NewCaddyDeployment(caddyImage, api.Placement{})
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
plan, err := d.Plan(ctx)
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
||||||
|
fmt.Println("Cancelled. No changes were made.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if _, err = d.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("deploy caddy: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
|
|
||||||
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
|
||||||
backoff.WithMaxInterval(1*time.Second),
|
|
||||||
backoff.WithMaxElapsedTime(5*time.Minute),
|
|
||||||
), ctx)
|
|
||||||
|
|
||||||
check := func() error {
|
|
||||||
_, err := client.ListMachines(ctx, nil)
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
statusErr := status.Convert(err)
|
|
||||||
if statusErr.Code() == codes.FailedPrecondition {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return backoff.Permanent(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return backoff.Retry(check, boff)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PublicIPNone is the value used to indicate removal of public IP
|
||||||
|
PublicIPNone = "none"
|
||||||
|
)
|
||||||
+112
-24
@@ -4,7 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/huh/v2/spinner"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
@@ -12,50 +16,85 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type initOptions struct {
|
type initOptions struct {
|
||||||
|
context string
|
||||||
dnsEndpoint string
|
dnsEndpoint string
|
||||||
name string
|
name string
|
||||||
network string
|
network string
|
||||||
noCaddy bool
|
noCaddy bool
|
||||||
noDNS bool
|
noDNS bool
|
||||||
|
noInstall bool
|
||||||
publicIP string
|
publicIP string
|
||||||
sshKey string
|
sshKey string
|
||||||
version string
|
version string
|
||||||
context string
|
wgEndpoints []string
|
||||||
|
yes bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInitCommand() *cobra.Command {
|
func NewInitCommand() *cobra.Command {
|
||||||
opts := initOptions{}
|
opts := initOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init [USER@HOST:PORT]",
|
Use: "init [schema://]USER@HOST[:PORT]",
|
||||||
Short: "Initialise a new cluster with a remote machine as the first member.",
|
Short: "Initialise a new cluster with a remote machine as the first member.",
|
||||||
// TODO: include usage examples of initialising a remote machine.
|
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.
|
||||||
|
|
||||||
|
Connection methods:
|
||||||
|
ssh://user@host - Use built-in SSH library (default, no prefix required)
|
||||||
|
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
|
||||||
|
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.
|
// TODO: support initialising a cluster on the local machine.
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||||
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
var remoteMachine *cli.RemoteMachine
|
var remoteMachine *cli.RemoteMachine
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
user, host, port, err := config.SSHDestination(args[0]).Parse()
|
// Determine if SSH CLI is requested and strip scheme
|
||||||
|
destination := args[0]
|
||||||
|
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
|
||||||
|
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
||||||
|
destination = strings.TrimPrefix(destination, "ssh://")
|
||||||
|
|
||||||
|
user, host, port, err := config.SSHDestination(destination).Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse remote machine: %w", err)
|
return fmt.Errorf("parse remote machine: %w", err)
|
||||||
}
|
}
|
||||||
remoteMachine = &cli.RemoteMachine{
|
remoteMachine = &cli.RemoteMachine{
|
||||||
User: user,
|
User: user,
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
KeyPath: opts.sshKey,
|
KeyPath: opts.sshKey,
|
||||||
|
UseSSHCLI: useSSHCLI,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
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,
|
cmd.Flags().StringVar(&opts.dnsEndpoint, "dns-endpoint", dns.DefaultUncloudDNSAPIEndpoint,
|
||||||
"API endpoint for the Uncloud DNS service.")
|
"API endpoint for the Uncloud DNS service.")
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
@@ -68,34 +107,55 @@ func NewInitCommand() *cobra.Command {
|
|||||||
)
|
)
|
||||||
cmd.Flags().BoolVar(
|
cmd.Flags().BoolVar(
|
||||||
&opts.noCaddy, "no-caddy", false,
|
&opts.noCaddy, "no-caddy", false,
|
||||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
"Don't deploy Caddy reverse proxy service to the machine. You can deploy it later with 'uc caddy deploy'.",
|
||||||
)
|
)
|
||||||
cmd.Flags().BoolVar(
|
cmd.Flags().BoolVar(
|
||||||
&opts.noDNS, "no-dns", false,
|
&opts.noDNS, "no-dns", false,
|
||||||
"Don't reserve a cluster domain in Uncloud DNS.",
|
"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, Uncloud daemon, and dependencies on the machine. "+
|
||||||
|
"Assumes they're already installed and running.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.publicIP, "public-ip", "auto",
|
&opts.publicIP, "public-ip", "auto",
|
||||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||||
"blank '' or 'none' to disable ingress on this machine, or specify an IP address.",
|
fmt.Sprintf("blank '' or '%s' to disable ingress on this machine, or specify an IP address.", PublicIPNone),
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "",
|
||||||
"Path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
fmt.Sprintf("Path to SSH private key for remote login (if not already added to SSH agent). (default %q)",
|
||||||
|
cli.DefaultSSHKeyPath),
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.version, "version", "latest",
|
&opts.version, "version", "latest",
|
||||||
"Version of the Uncloud daemon to install on the machine.",
|
"Version of the Uncloud daemon to install on the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringSliceVar(
|
||||||
&opts.context, "context", "c", "default",
|
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||||
"Name of the created context for the initialised cluster in the Uncloud config.",
|
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
||||||
|
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
||||||
|
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
||||||
|
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
||||||
|
"If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
|
||||||
)
|
)
|
||||||
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
|
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.
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
|
||||||
netPrefix, err := netip.ParsePrefix(opts.network)
|
netPrefix, err := netip.ParsePrefix(opts.network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse network CIDR: %w", err)
|
return fmt.Errorf("parse network CIDR: %w", err)
|
||||||
@@ -105,7 +165,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
switch opts.publicIP {
|
switch opts.publicIP {
|
||||||
case "auto":
|
case "auto":
|
||||||
publicIP = &netip.Addr{}
|
publicIP = &netip.Addr{}
|
||||||
case "", "none":
|
case "", PublicIPNone:
|
||||||
publicIP = nil
|
publicIP = nil
|
||||||
default:
|
default:
|
||||||
ip, err := netip.ParseAddr(opts.publicIP)
|
ip, err := netip.ParseAddr(opts.publicIP)
|
||||||
@@ -114,28 +174,56 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
}
|
}
|
||||||
publicIP = &ip
|
publicIP = &ip
|
||||||
}
|
}
|
||||||
|
initOpts := cli.InitClusterOptions{
|
||||||
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{
|
|
||||||
Context: opts.context,
|
Context: opts.context,
|
||||||
MachineName: opts.name,
|
MachineName: opts.name,
|
||||||
Network: netPrefix,
|
Network: netPrefix,
|
||||||
PublicIP: publicIP,
|
PublicIP: publicIP,
|
||||||
RemoteMachine: remoteMachine,
|
RemoteMachine: remoteMachine,
|
||||||
|
SkipInstall: opts.noInstall,
|
||||||
Version: opts.version,
|
Version: opts.version,
|
||||||
})
|
AutoConfirm: opts.yes,
|
||||||
|
}
|
||||||
|
if len(opts.wgEndpoints) > 0 {
|
||||||
|
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||||
|
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
||||||
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
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 = spinner.New().
|
||||||
|
Title(" Waiting for the cluster to be ready...").
|
||||||
|
Type(spinner.MiniDot).
|
||||||
|
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
|
||||||
|
return &spinner.Styles{
|
||||||
|
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||||
|
Title: lipgloss.NewStyle(),
|
||||||
|
}
|
||||||
|
})).
|
||||||
|
ActionWithErr(func(ctx context.Context) error {
|
||||||
|
return client.WaitClusterReady(ctx, 1*time.Minute)
|
||||||
|
}).
|
||||||
|
Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wait for cluster to be ready: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Cluster is ready.")
|
||||||
|
|
||||||
if opts.noCaddy && opts.noDNS {
|
if opts.noCaddy && opts.noDNS {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deploy the Caddy service to the initialised machine.
|
|
||||||
// The creation of a deployment plan talks to cluster API. Since the API needs a few moments to become available
|
|
||||||
// after cluster initialisation, we keep the user informed during this wait.
|
|
||||||
fmt.Println("Waiting for the machine to be ready...")
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
if !opts.noDNS {
|
if !opts.noDNS {
|
||||||
@@ -147,7 +235,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.noCaddy {
|
if !opts.noCaddy {
|
||||||
d, err := client.NewCaddyDeployment("", api.Placement{})
|
d, err := client.NewCaddyDeployment("", "", api.Placement{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,25 +14,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
var clusterContext string
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List machines in a cluster.",
|
Short: "List machines in a cluster.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return list(cmd.Context(), uncli, clusterContext)
|
return list(cmd.Context(), uncli)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&clusterContext, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
func list(ctx context.Context, uncli *cli.CLI) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, clusterName)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -46,7 +41,7 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
|||||||
// Print the list of machines in a table format.
|
// Print the list of machines in a table format.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
// Print header.
|
// Print header.
|
||||||
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS"); err != nil {
|
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS\tMACHINE ID"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
// Print rows.
|
// Print rows.
|
||||||
@@ -68,7 +63,8 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err = fmt.Fprintf(
|
if _, err = fmt.Fprintf(
|
||||||
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP, strings.Join(endpoints, ", "),
|
tw, "%s\t%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||||
|
strings.Join(endpoints, ", "), member.Machine.Id,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,260 @@
|
|||||||
|
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/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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 and list all service containers on it including stopped ones.
|
||||||
|
mctx, machines, err := client.ProxyMachinesContext(ctx, []string{nameOrID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(machines) == 0 {
|
||||||
|
return fmt.Errorf("machine '%s' not found in the cluster", nameOrID)
|
||||||
|
}
|
||||||
|
m := machines[0].Machine
|
||||||
|
|
||||||
|
// Verify if the machine being removed is the proxy machine we're connected to.
|
||||||
|
proxyMachine, err := client.MachineClient.Inspect(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect proxy machine: %w", err)
|
||||||
|
}
|
||||||
|
if proxyMachine.Id == m.Id {
|
||||||
|
allMachines, err := client.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
if len(allMachines) > 1 {
|
||||||
|
return errors.New("cannot remove the machine you are currently connected to. " +
|
||||||
|
"Please connect to another machine in the cluster and try again. " +
|
||||||
|
"Use --connect flag or update 'connections' for the cluster context in your Uncloud config")
|
||||||
|
// It's ok to remove the proxy machine if it's the last one in the cluster.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: mark the machine as being removed and unschedulable when this is possible to prevent new containers
|
||||||
|
// from being scheduled on it while the removal is in progress.
|
||||||
|
|
||||||
|
reset := !opts.noReset
|
||||||
|
var containers []api.ServiceContainer
|
||||||
|
reachable := false
|
||||||
|
if reset {
|
||||||
|
// Check if the machine is up and has service containers.
|
||||||
|
listOpts := container.ListOptions{All: true}
|
||||||
|
machineContainers, err := client.Docker.ListServiceContainers(mctx, "", listOpts)
|
||||||
|
if err == nil {
|
||||||
|
reachable = true
|
||||||
|
containers = machineContainers[0].Containers
|
||||||
|
if len(containers) > 0 {
|
||||||
|
plural := ""
|
||||||
|
if len(containers) > 1 {
|
||||||
|
plural = "s"
|
||||||
|
}
|
||||||
|
fmt.Printf("Found %d service container%s on machine '%s':\n", len(containers), plural, m.Name)
|
||||||
|
fmt.Println(formatContainerTree(containers))
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("This will remove all service containers from the machine, remove it from the cluster, " +
|
||||||
|
"and reset it to the uninitialised state.")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("No service containers found on machine '%s'.\n", m.Name)
|
||||||
|
fmt.Println("This will remove the machine from the cluster and reset it to the uninitialised state.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("This will remove machine '%s' from the cluster without resetting it as it's unreachable.\n",
|
||||||
|
m.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("This will remove machine '%s' from the cluster without resetting it.\n", m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.yes {
|
||||||
|
confirmed, err := tui.Confirm("")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm removal: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. Machine was not removed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reset && len(containers) > 0 {
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
return removeContainers(ctx, client, containers)
|
||||||
|
}, uncli.ProgressOut(), "Removing containers")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove containers: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = client.RemoveMachine(ctx, &pb.RemoveMachineRequest{Id: m.Id}); err != nil {
|
||||||
|
return fmt.Errorf("remove machine from cluster: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
|
||||||
|
|
||||||
|
if reset && reachable {
|
||||||
|
_, err = client.MachineClient.Reset(mctx, &pb.ResetRequest{})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("WARNING: Failed to reset machine: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Machine reset initiated and will complete in the background.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, ctr.Config.Image, state)
|
||||||
|
t.Child(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
output = append(output, t.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(output, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeContainers removes the given service containers from the machine.
|
||||||
|
func removeContainers(ctx context.Context, client api.Client, containers []api.ServiceContainer) error {
|
||||||
|
if len(containers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
errCh := make(chan error)
|
||||||
|
|
||||||
|
for _, ctr := range containers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(c api.ServiceContainer) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Gracefully stop the container before removing it.
|
||||||
|
err := client.StopContainer(ctx, c.ServiceID(), c.ID, container.StopOptions{})
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
errCh <- fmt.Errorf("stop container '%s': %w", c.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.RemoveContainer(ctx, c.ServiceID(), c.ID, container.RemoveOptions{
|
||||||
|
// Remove anonymous volumes created by the container.
|
||||||
|
RemoveVolumes: true,
|
||||||
|
})
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
errCh <- fmt.Errorf("remove container '%s': %w", c.ID, err)
|
||||||
|
}
|
||||||
|
}(ctr)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
for e := range errCh {
|
||||||
|
err = errors.Join(err, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -6,14 +6,17 @@ import (
|
|||||||
|
|
||||||
func NewRootCommand() *cobra.Command {
|
func NewRootCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "machine",
|
Use: "machine",
|
||||||
Short: "Manage machines in an Uncloud cluster.",
|
Aliases: []string{"m"},
|
||||||
|
Short: "Manage machines in the cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewAddCommand(),
|
NewAddCommand(),
|
||||||
NewInitCommand(),
|
NewInitCommand(),
|
||||||
NewListCommand(),
|
NewListCommand(),
|
||||||
NewTokenCommand(),
|
NewRenameCommand(),
|
||||||
|
NewRmCommand(),
|
||||||
|
NewUpdateCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package machine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/psviderski/uncloud/internal/daemon"
|
|
||||||
"github.com/psviderski/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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"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])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
||||||
|
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
||||||
|
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
||||||
|
"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()
|
||||||
|
|
||||||
|
// First, resolve the machine to get its ID
|
||||||
|
machine, err := client.InspectMachine(ctx, machineNameOrID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find machine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the update request
|
||||||
|
req := &pb.UpdateMachineRequest{
|
||||||
|
MachineId: machine.Machine.Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.name != "" {
|
||||||
|
req.Name = &opts.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if --public-ip flag was explicitly provided
|
||||||
|
if cmd.Flags().Changed("public-ip") {
|
||||||
|
if opts.publicIP == "" || opts.publicIP == PublicIPNone {
|
||||||
|
req.PublicIp = &pb.IP{} // Empty IP to signal removal
|
||||||
|
} else {
|
||||||
|
// Parse and validate the public IP
|
||||||
|
ip, err := netip.ParseAddr(opts.publicIP)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid public IP address %q: %w", opts.publicIP, err)
|
||||||
|
}
|
||||||
|
req.PublicIp = pb.NewIP(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the update operation
|
||||||
|
updatedMachine, err := client.UpdateMachine(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update machine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report what was changed
|
||||||
|
changes := make([]string, 0)
|
||||||
|
if opts.name != "" {
|
||||||
|
changes = append(changes, fmt.Sprintf("name: %q -> %q", machine.Machine.Name, updatedMachine.Name))
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("public-ip") {
|
||||||
|
oldIP := PublicIPNone
|
||||||
|
if machine.Machine.PublicIp != nil {
|
||||||
|
if addr, err := machine.Machine.PublicIp.ToAddr(); err == nil {
|
||||||
|
oldIP = addr.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newIP := PublicIPNone
|
||||||
|
if updatedMachine.PublicIp != nil {
|
||||||
|
if addr, err := updatedMachine.PublicIp.ToAddr(); err == nil {
|
||||||
|
newIP = addr.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes = append(changes, fmt.Sprintf("public IP: %s -> %s", oldIP, newIP))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
+67
-17
@@ -6,15 +6,19 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/image"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/wg"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"github.com/psviderski/uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
|
"github.com/psviderski/uncloud/internal/log"
|
||||||
"github.com/psviderski/uncloud/internal/version"
|
"github.com/psviderski/uncloud/internal/version"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -22,17 +26,24 @@ import (
|
|||||||
type globalOptions struct {
|
type globalOptions struct {
|
||||||
configPath string
|
configPath string
|
||||||
connect string
|
connect string
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
log.InitLoggerFromEnv()
|
||||||
|
|
||||||
opts := globalOptions{}
|
opts := globalOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uncloud",
|
Use: "uc",
|
||||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.",
|
||||||
Version: version.String(),
|
Version: version.String(),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
|
||||||
|
cli.BindEnvToFlag(cmd, "context", "UNCLOUD_CONTEXT")
|
||||||
|
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
|
||||||
|
|
||||||
var conn *config.MachineConnection
|
var conn *config.MachineConnection
|
||||||
if opts.connect != "" {
|
if opts.connect != "" {
|
||||||
if strings.HasPrefix(opts.connect, "tcp://") {
|
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||||
@@ -43,11 +54,17 @@ func main() {
|
|||||||
conn = &config.MachineConnection{
|
conn = &config.MachineConnection{
|
||||||
TCP: &addrPort,
|
TCP: &addrPort,
|
||||||
}
|
}
|
||||||
} else {
|
} else if strings.HasPrefix(opts.connect, "ssh+cli://") {
|
||||||
dest := opts.connect
|
dest := opts.connect[len("ssh+cli://"):]
|
||||||
if strings.HasPrefix(dest, "ssh://") {
|
conn = &config.MachineConnection{
|
||||||
dest = dest[len("ssh://"):]
|
SSHCLI: config.SSHDestination(dest),
|
||||||
}
|
}
|
||||||
|
} else if strings.HasPrefix(opts.connect, "unix://") {
|
||||||
|
conn = &config.MachineConnection{
|
||||||
|
Unix: opts.connect[len("unix://"):],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dest := strings.TrimPrefix(opts.connect, "ssh://")
|
||||||
conn = &config.MachineConnection{
|
conn = &config.MachineConnection{
|
||||||
SSH: config.SSHDestination(dest),
|
SSH: config.SSHDestination(dest),
|
||||||
}
|
}
|
||||||
@@ -55,7 +72,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configPath := fs.ExpandHomeDir(opts.configPath)
|
configPath := fs.ExpandHomeDir(opts.configPath)
|
||||||
uncli, err := cli.New(configPath, conn)
|
uncli, err := cli.New(configPath, conn, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialise CLI: %w", err)
|
return fmt.Errorf("initialise CLI: %w", err)
|
||||||
}
|
}
|
||||||
@@ -65,27 +82,60 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||||
"Connect to a remote cluster machine without using the Uncloud configuration file.\n"+
|
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
||||||
"Format: [ssh://]user@host[:port] or tcp://host:port")
|
"Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock")
|
||||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
|
||||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||||
"Path to the Uncloud configuration file.")
|
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
||||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||||
// TODO: make --context a global flag and pass it as a value of the command context.
|
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" {
|
||||||
|
urlStyle := lipgloss.NewStyle().
|
||||||
|
Underline(true).
|
||||||
|
Foreground(lipgloss.Color("12")) // light blue
|
||||||
|
|
||||||
|
fmt.Fprintln(c.OutOrStdout())
|
||||||
|
fmt.Fprintf(c.OutOrStdout(), "Learn more about Uncloud: %s\n",
|
||||||
|
urlStyle.Render("https://uncloud.run/docs"))
|
||||||
|
fmt.Fprintf(c.OutOrStdout(), "Join our Discord community: %s\n",
|
||||||
|
urlStyle.Render("https://uncloud.run/discord"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd.AddGroup(&cobra.Group{
|
||||||
|
ID: "service",
|
||||||
|
Title: "Deploy and manage services:",
|
||||||
|
})
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
NewBuildCommand(),
|
||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
|
NewDocsCommand(),
|
||||||
|
NewImagesCommand(),
|
||||||
|
NewPsCommand(),
|
||||||
caddy.NewRootCommand(),
|
caddy.NewRootCommand(),
|
||||||
cmdcontext.NewRootCommand(),
|
cmdcontext.NewRootCommand(),
|
||||||
dns.NewRootCommand(),
|
dns.NewRootCommand(),
|
||||||
|
image.NewRootCommand(),
|
||||||
machine.NewRootCommand(),
|
machine.NewRootCommand(),
|
||||||
service.NewRootCommand(),
|
service.NewRootCommand(),
|
||||||
service.NewInspectCommand(),
|
service.NewExecCommand("service"),
|
||||||
service.NewListCommand(),
|
service.NewInspectCommand("service"),
|
||||||
service.NewRmCommand(),
|
service.NewListCommand("service"),
|
||||||
service.NewRunCommand(),
|
service.NewLogsCommand("service"),
|
||||||
service.NewScaleCommand(),
|
service.NewRmCommand("service"),
|
||||||
|
service.NewRunCommand("service"),
|
||||||
|
service.NewScaleCommand("service"),
|
||||||
|
service.NewStartCommand("service"),
|
||||||
|
service.NewStopCommand("service"),
|
||||||
volume.NewRootCommand(),
|
volume.NewRootCommand(),
|
||||||
|
wg.NewRootCommand(),
|
||||||
)
|
)
|
||||||
cobra.CheckErr(cmd.Execute())
|
cobra.CheckErr(cmd.Execute())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/huh/v2/spinner"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"charm.land/lipgloss/v2/table"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
name string
|
||||||
|
image string
|
||||||
|
status string
|
||||||
|
highlight containerHighlight
|
||||||
|
created time.Time
|
||||||
|
ip 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 = spinner.New().
|
||||||
|
Title(" Collecting container info...").
|
||||||
|
Type(spinner.MiniDot).
|
||||||
|
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
|
||||||
|
return &spinner.Styles{
|
||||||
|
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||||
|
Title: lipgloss.NewStyle(),
|
||||||
|
}
|
||||||
|
})).
|
||||||
|
ActionWithErr(func(ctx context.Context) error {
|
||||||
|
containers, err = collectContainers(ctx, clusterClient)
|
||||||
|
return err
|
||||||
|
}).
|
||||||
|
Run()
|
||||||
|
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 := table.New().
|
||||||
|
// Remove the default border.
|
||||||
|
Border(lipgloss.Border{}).
|
||||||
|
BorderTop(false).
|
||||||
|
BorderBottom(false).
|
||||||
|
BorderLeft(false).
|
||||||
|
BorderRight(false).
|
||||||
|
BorderHeader(false).
|
||||||
|
BorderColumn(false).
|
||||||
|
StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
|
if row == table.HeaderRow {
|
||||||
|
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
|
||||||
|
}
|
||||||
|
// Regular style for data rows with padding.
|
||||||
|
return lipgloss.NewStyle().PaddingRight(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Headers("SERVICE", "CONTAINER ID", "CONTAINER NAME", "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
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Row(
|
||||||
|
ctr.serviceName,
|
||||||
|
id,
|
||||||
|
ctr.name,
|
||||||
|
ctr.image,
|
||||||
|
created,
|
||||||
|
statusStyle.Render(ctr.status),
|
||||||
|
ctr.ip,
|
||||||
|
ctr.machineName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(t)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo, error) {
|
||||||
|
listCtx, machines, err := cli.ProxyMachinesContext(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("proxy machines context: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of IP to machine name for resolving response metadata
|
||||||
|
machinesNamesByIP := make(map[string]string)
|
||||||
|
for _, m := range machines {
|
||||||
|
if addr, err := m.Machine.Network.ManagementIp.ToAddr(); err == nil {
|
||||||
|
machinesNamesByIP[addr.String()] = m.Machine.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Metadata can be nil if the request was broadcasted to only one machine.
|
||||||
|
if msc.Metadata == nil && len(machineContainers) > 1 {
|
||||||
|
return nil, fmt.Errorf("something went wrong with gRPC proxy: metadata is missing for a machine response")
|
||||||
|
}
|
||||||
|
|
||||||
|
machineName := "unknown"
|
||||||
|
if msc.Metadata != nil {
|
||||||
|
var ok bool
|
||||||
|
machineName, ok = machinesNamesByIP[msc.Metadata.Machine]
|
||||||
|
if !ok {
|
||||||
|
// Fallback to machine's IP as name.
|
||||||
|
machineName = msc.Metadata.Machine
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to the first available machine name.
|
||||||
|
if len(machines) > 0 {
|
||||||
|
machineName = machines[0].Machine.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msc.Metadata != nil && msc.Metadata.Error != "" {
|
||||||
|
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
|
||||||
|
msc.Metadata.Error))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ctr := range msc.Containers {
|
||||||
|
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 { // 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,
|
||||||
|
name: ctr.Container.Name,
|
||||||
|
image: ctr.Container.Config.Image,
|
||||||
|
status: status,
|
||||||
|
highlight: highlight,
|
||||||
|
created: created,
|
||||||
|
ip: ipStr,
|
||||||
|
}
|
||||||
|
containers = append(containers, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return containers, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/netip"
|
||||||
|
"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"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockDockerClient implements pb.DockerClient
|
||||||
|
type mockDockerClient struct {
|
||||||
|
pb.DockerClient // Embed to avoid implementing all methods
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockClusterClient implements pb.ClusterClient
|
||||||
|
type mockClusterClient struct {
|
||||||
|
pb.ClusterClient // Embed to avoid implementing all methods
|
||||||
|
machinesResp *pb.ListMachinesResponse
|
||||||
|
machinesErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockClusterClient) ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*pb.ListMachinesResponse, error) {
|
||||||
|
return m.machinesResp, m.machinesErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectContainers_NilMetadata(t *testing.T) {
|
||||||
|
// Setup container data
|
||||||
|
containerData := map[string]any{
|
||||||
|
"Id": "container1",
|
||||||
|
"Name": "test-container",
|
||||||
|
"Config": map[string]any{
|
||||||
|
"Image": "test-image",
|
||||||
|
},
|
||||||
|
"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{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
containerJSON, err := json.Marshal(containerData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
serviceSpecJSON, err := json.Marshal(map[string]any{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
mockDocker := &mockDockerClient{
|
||||||
|
listResp: &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Metadata: nil, // Simulating the issue: nil metadata
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
machineIP := "10.0.0.1"
|
||||||
|
mockCluster := &mockClusterClient{
|
||||||
|
machinesResp: &pb.ListMachinesResponse{
|
||||||
|
Machines: []*pb.MachineMember{
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-1",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr(machineIP)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
State: pb.MachineMember_UP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct client with mocks
|
||||||
|
cli := &client.Client{
|
||||||
|
Docker: &docker.Client{
|
||||||
|
GRPCClient: mockDocker,
|
||||||
|
},
|
||||||
|
ClusterClient: mockCluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
containers, err := collectContainers(context.Background(), cli)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.Len(t, containers, 1)
|
||||||
|
if len(containers) > 0 {
|
||||||
|
c := containers[0]
|
||||||
|
assert.Equal(t, "container1", c.id)
|
||||||
|
assert.Equal(t, "test-container", c.name)
|
||||||
|
assert.Equal(t, "machine-1", c.machineName, "Should fall back to the single machine name when metadata is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectContainers_NilMetadata_MultipleMachines_Error(t *testing.T) {
|
||||||
|
// If we have multiple machines but receive nil metadata, it should return an error as it is ambiguous
|
||||||
|
|
||||||
|
// Setup container data
|
||||||
|
containerData1 := map[string]any{
|
||||||
|
"Id": "container1",
|
||||||
|
}
|
||||||
|
containerJSON1, _ := json.Marshal(containerData1)
|
||||||
|
|
||||||
|
containerData2 := map[string]any{
|
||||||
|
"Id": "container2",
|
||||||
|
"Config": map[string]any{
|
||||||
|
"Image": "test-image",
|
||||||
|
},
|
||||||
|
"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{})
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
mockDocker := &mockDockerClient{
|
||||||
|
listResp: &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Metadata: nil, // Nil metadata
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON1,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON2,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockCluster := &mockClusterClient{
|
||||||
|
machinesResp: &pb.ListMachinesResponse{
|
||||||
|
Machines: []*pb.MachineMember{
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-1",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-2",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct client with mocks
|
||||||
|
cli := &client.Client{
|
||||||
|
Docker: &docker.Client{
|
||||||
|
GRPCClient: mockDocker,
|
||||||
|
},
|
||||||
|
ClusterClient: mockCluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
_, err := collectContainers(context.Background(), cli)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "metadata is missing for a machine response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
|
||||||
|
// Verify correct mapping of containers to machines when metadata is present
|
||||||
|
|
||||||
|
// Setup container data
|
||||||
|
containerData1 := map[string]any{
|
||||||
|
"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{})
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
mockDocker := &mockDockerClient{
|
||||||
|
listResp: &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Metadata: &pb.Metadata{Machine: "10.0.0.1"},
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON1,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON2,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockCluster := &mockClusterClient{
|
||||||
|
machinesResp: &pb.ListMachinesResponse{
|
||||||
|
Machines: []*pb.MachineMember{
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-1",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-2",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := &client.Client{
|
||||||
|
Docker: &docker.Client{GRPCClient: mockDocker},
|
||||||
|
ClusterClient: mockCluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
containers, err := collectContainers(context.Background(), cli)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, containers, 2)
|
||||||
|
// Order is not guaranteed by the map iteration in logic or parallel fetch (though here it's mocked sequential),
|
||||||
|
// but collectContainers just appends.
|
||||||
|
// We'll find them by ID.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
|
||||||
|
// Case: 1 msc with nil metadata but no machines at all
|
||||||
|
|
||||||
|
containerData := map[string]any{
|
||||||
|
"Id": "container1",
|
||||||
|
"Config": map[string]any{
|
||||||
|
"Image": "test-image",
|
||||||
|
},
|
||||||
|
"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{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
containerJSON, _ := json.Marshal(containerData)
|
||||||
|
serviceSpecJSON, _ := json.Marshal(map[string]any{})
|
||||||
|
|
||||||
|
mockDocker := &mockDockerClient{
|
||||||
|
listResp: &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Metadata: nil,
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// No machines in cluster response
|
||||||
|
mockCluster := &mockClusterClient{
|
||||||
|
machinesResp: &pb.ListMachinesResponse{
|
||||||
|
Machines: []*pb.MachineMember{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := &client.Client{
|
||||||
|
Docker: &docker.Client{GRPCClient: mockDocker},
|
||||||
|
ClusterClient: mockCluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
containers, err := collectContainers(context.Background(), cli)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, containers, 1)
|
||||||
|
if len(containers) > 0 {
|
||||||
|
assert.Equal(t, "unknown", containers[0].machineName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) {
|
||||||
|
// Case: msc with metadata that is not in the IP-to-name mapping
|
||||||
|
|
||||||
|
containerData := map[string]any{
|
||||||
|
"Id": "container1",
|
||||||
|
"Config": map[string]any{
|
||||||
|
"Image": "test-image",
|
||||||
|
},
|
||||||
|
"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{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
containerJSON, _ := json.Marshal(containerData)
|
||||||
|
serviceSpecJSON, _ := json.Marshal(map[string]any{})
|
||||||
|
|
||||||
|
mockDocker := &mockDockerClient{
|
||||||
|
listResp: &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Metadata: &pb.Metadata{Machine: "10.0.0.99"}, // Unknown IP
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockCluster := &mockClusterClient{
|
||||||
|
machinesResp: &pb.ListMachinesResponse{
|
||||||
|
Machines: []*pb.MachineMember{
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-1",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := &client.Client{
|
||||||
|
Docker: &docker.Client{GRPCClient: mockDocker},
|
||||||
|
ClusterClient: mockCluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
containers, err := collectContainers(context.Background(), cli)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, containers, 1)
|
||||||
|
if len(containers) > 0 {
|
||||||
|
// Should fallback to the IP/string in metadata
|
||||||
|
assert.Equal(t, "10.0.0.99", containers[0].machineName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/docker/cli/cli/streams"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"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 := args[0]
|
||||||
|
command := args[1:]
|
||||||
|
if len(command) == 0 {
|
||||||
|
command = DEFAULT_COMMAND
|
||||||
|
}
|
||||||
|
return runExec(cmd.Context(), uncli, serviceName, command, opts)
|
||||||
|
},
|
||||||
|
GroupID: groupID,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
@@ -4,25 +4,25 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/pkg/stringid"
|
"github.com/docker/docker/pkg/stringid"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
service string
|
service string
|
||||||
cluster string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInspectCommand() *cobra.Command {
|
func NewInspectCommand(groupID string) *cobra.Command {
|
||||||
opts := inspectOptions{}
|
opts := inspectOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "inspect",
|
Use: "inspect SERVICE",
|
||||||
Short: "Display detailed information on a service.",
|
Short: "Display detailed information on a service.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -30,16 +30,13 @@ func NewInspectCommand() *cobra.Command {
|
|||||||
opts.service = args[0]
|
opts.service = args[0]
|
||||||
return inspect(cmd.Context(), uncli, opts)
|
return inspect(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: groupID,
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.cluster, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -59,23 +56,31 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
|||||||
machinesNamesByID[m.Machine.Id] = m.Machine.Name
|
machinesNamesByID[m.Machine.Id] = m.Machine.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("ID: %s\n", svc.ID)
|
fmt.Printf("Service ID: %s\n", svc.ID)
|
||||||
fmt.Printf("Name: %s\n", svc.Name)
|
fmt.Printf("Name: %s\n", svc.Name)
|
||||||
fmt.Printf("Mode: %s\n", svc.Mode)
|
fmt.Printf("Mode: %s\n", svc.Mode)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
|
// Parse created times for sorting and display.
|
||||||
|
createdTimes := make(map[string]time.Time, len(svc.Containers))
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
createdTimes[ctr.Container.ID], _ = time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort containers by created time (newest first).
|
||||||
|
slices.SortFunc(svc.Containers, 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.
|
// Print the list of containers in a table format.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tMACHINE"); err != nil {
|
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tIP ADDRESS\tMACHINE"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
for _, ctr := range svc.Containers {
|
for _, ctr := range svc.Containers {
|
||||||
createdAt, err := time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse created time: %w", err)
|
|
||||||
}
|
|
||||||
created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
|
||||||
|
|
||||||
machine := machinesNamesByID[ctr.MachineID]
|
machine := machinesNamesByID[ctr.MachineID]
|
||||||
if machine == "" {
|
if machine == "" {
|
||||||
@@ -86,13 +91,21 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
|||||||
return fmt.Errorf("get human state: %w", err)
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
_, err = fmt.Fprintf(
|
_, err = fmt.Fprintf(
|
||||||
tw,
|
tw,
|
||||||
"%s\t%s\t%s\t%s\t%s\n",
|
"%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
stringid.TruncateID(ctr.Container.ID),
|
stringid.TruncateID(ctr.Container.ID),
|
||||||
ctr.Container.Config.Image,
|
ctr.Container.Config.Image,
|
||||||
created,
|
created,
|
||||||
state,
|
state,
|
||||||
|
ipStr,
|
||||||
machine,
|
machine,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
|
"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/psviderski/uncloud/pkg/client/compose"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type logsOptions struct {
|
||||||
|
files []string
|
||||||
|
follow bool
|
||||||
|
tail string
|
||||||
|
since string
|
||||||
|
until string
|
||||||
|
utc bool
|
||||||
|
machines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogsCommand(groupID string) *cobra.Command {
|
||||||
|
var options logsOptions
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "logs [SERVICE...]",
|
||||||
|
Aliases: []string{"log"},
|
||||||
|
Short: "View service logs.",
|
||||||
|
Long: `View logs from all replicas of the specified service(s) across all machines in the cluster.
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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().BoolVarP(&options.follow, "follow", "f", false,
|
||||||
|
"Continually stream new logs.")
|
||||||
|
cmd.Flags().StringSliceVarP(&options.machines, "machine", "m", nil,
|
||||||
|
"Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.")
|
||||||
|
cmd.Flags().StringVar(&options.since, "since", "",
|
||||||
|
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||||
|
"Examples:\n"+
|
||||||
|
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
|
||||||
|
" --since 1h Relative duration (1 hour ago)\n"+
|
||||||
|
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
|
||||||
|
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
|
||||||
|
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
||||||
|
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
|
||||||
|
cmd.Flags().StringVarP(&options.tail, "tail", "n", "100",
|
||||||
|
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.")
|
||||||
|
cmd.Flags().StringVar(&options.until, "until", "",
|
||||||
|
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||||
|
"See --since for examples.")
|
||||||
|
cmd.Flags().BoolVar(&options.utc, "utc", false,
|
||||||
|
"Print timestamps in UTC instead of local timezone.")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logsOptions) error {
|
||||||
|
// If no services specified, try to load them from the Compose file(s).
|
||||||
|
fromCompose := false
|
||||||
|
if len(serviceNames) == 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.
|
||||||
|
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...)
|
||||||
|
if len(serviceNames) == 0 {
|
||||||
|
return errors.New("no services found in Compose file(s)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse tail option.
|
||||||
|
tail := -1
|
||||||
|
if opts.tail != "all" {
|
||||||
|
tailInt, err := strconv.Atoi(opts.tail)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid --tail value '%s': %w", opts.tail, err)
|
||||||
|
}
|
||||||
|
tail = tailInt
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(serviceNames))
|
||||||
|
var foundServices, notFoundServices []string
|
||||||
|
|
||||||
|
for _, serviceName := range serviceNames {
|
||||||
|
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, api.ErrNotFound) && fromCompose {
|
||||||
|
notFoundServices = append(notFoundServices, serviceName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err)
|
||||||
|
}
|
||||||
|
svcStreams = append(svcStreams, ch)
|
||||||
|
foundServices = append(foundServices, serviceName)
|
||||||
|
|
||||||
|
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, ", "))
|
||||||
|
}
|
||||||
|
serviceNames = foundServices
|
||||||
|
|
||||||
|
for _, name := range notFoundServices {
|
||||||
|
tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream <-chan api.ServiceLogEntry
|
||||||
|
if len(serviceNames) == 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.
|
||||||
|
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 := newLogFormatter(machineNames, serviceNames, opts.utc)
|
||||||
|
|
||||||
|
// Print merged logs.
|
||||||
|
for entry := range stream {
|
||||||
|
if entry.Err != nil {
|
||||||
|
formatter.printError(entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
formatter.printEntry(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available colors for machine/service differentiation.
|
||||||
|
var colorPalette = []color.Color{
|
||||||
|
lipgloss.BrightGreen,
|
||||||
|
lipgloss.BrightYellow,
|
||||||
|
lipgloss.BrightBlue,
|
||||||
|
lipgloss.BrightMagenta,
|
||||||
|
lipgloss.BrightCyan,
|
||||||
|
lipgloss.Green,
|
||||||
|
lipgloss.Yellow,
|
||||||
|
lipgloss.Blue,
|
||||||
|
lipgloss.Magenta,
|
||||||
|
lipgloss.Cyan,
|
||||||
|
}
|
||||||
|
|
||||||
|
// logFormatter handles formatting and printing of log entries with dynamic column alignment.
|
||||||
|
type logFormatter struct {
|
||||||
|
machineNames []string
|
||||||
|
serviceNames []string
|
||||||
|
|
||||||
|
maxMachineWidth int
|
||||||
|
maxServiceWidth int
|
||||||
|
|
||||||
|
utc bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLogFormatter(machineNames, serviceNames []string, utc bool) *logFormatter {
|
||||||
|
slices.Sort(machineNames)
|
||||||
|
slices.Sort(serviceNames)
|
||||||
|
|
||||||
|
maxMachineWidth := 0
|
||||||
|
for _, name := range machineNames {
|
||||||
|
if len(name) > maxMachineWidth {
|
||||||
|
maxMachineWidth = len(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxServiceWidth := 0
|
||||||
|
for _, name := range serviceNames {
|
||||||
|
if len(name) > maxServiceWidth {
|
||||||
|
maxServiceWidth = len(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &logFormatter{
|
||||||
|
machineNames: machineNames,
|
||||||
|
serviceNames: serviceNames,
|
||||||
|
maxMachineWidth: maxMachineWidth,
|
||||||
|
maxServiceWidth: maxServiceWidth,
|
||||||
|
utc: utc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTimestamp formats timestamp using local timezone or UTC if configured.
|
||||||
|
func (f *logFormatter) formatTimestamp(t time.Time) string {
|
||||||
|
if f.utc {
|
||||||
|
t = t.UTC()
|
||||||
|
} else {
|
||||||
|
t = t.In(time.Local)
|
||||||
|
}
|
||||||
|
dimStyle := lipgloss.NewStyle().Faint(true)
|
||||||
|
|
||||||
|
return dimStyle.Render(t.Format(time.StampMilli))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *logFormatter) formatMachine(name string) string {
|
||||||
|
style := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxMachineWidth - len(name))
|
||||||
|
|
||||||
|
if len(f.serviceNames) == 1 {
|
||||||
|
// Machine name is coloured for single-service logs.
|
||||||
|
i := slices.Index(f.machineNames, name)
|
||||||
|
if i == -1 {
|
||||||
|
f.machineNames = append(f.machineNames, name)
|
||||||
|
i = len(f.machineNames) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
style = style.Foreground(colorPalette[i%len(colorPalette)])
|
||||||
|
}
|
||||||
|
|
||||||
|
return style.Render(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *logFormatter) formatServiceContainer(serviceName, containerID string) string {
|
||||||
|
styleService := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxServiceWidth - len(serviceName))
|
||||||
|
styleContainer := lipgloss.NewStyle().Faint(true)
|
||||||
|
|
||||||
|
if len(f.serviceNames) > 1 {
|
||||||
|
// Service name is coloured for multi-service logs.
|
||||||
|
i := slices.Index(f.serviceNames, serviceName)
|
||||||
|
if i == -1 {
|
||||||
|
f.serviceNames = append(f.serviceNames, serviceName)
|
||||||
|
i = len(f.serviceNames) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
styleService = styleService.Foreground(colorPalette[i%len(colorPalette)])
|
||||||
|
}
|
||||||
|
|
||||||
|
return styleService.Render(serviceName) + styleContainer.Render("["+containerID[:5]+"]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// printEntry prints a single log entry with proper formatting.
|
||||||
|
func (f *logFormatter) printEntry(entry api.ServiceLogEntry) {
|
||||||
|
if entry.Stream != api.LogStreamStdout && entry.Stream != api.LogStreamStderr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var output strings.Builder
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
output.WriteString(f.formatTimestamp(entry.Timestamp))
|
||||||
|
output.WriteString(" ")
|
||||||
|
|
||||||
|
// Machine name
|
||||||
|
output.WriteString(f.formatMachine(entry.Metadata.MachineName))
|
||||||
|
output.WriteString(" ")
|
||||||
|
|
||||||
|
// Service[container_id]
|
||||||
|
output.WriteString(f.formatServiceContainer(entry.Metadata.ServiceName, entry.Metadata.ContainerID))
|
||||||
|
output.WriteString(" ")
|
||||||
|
|
||||||
|
// Message
|
||||||
|
output.Write(entry.Message)
|
||||||
|
|
||||||
|
// Print to appropriate stream.
|
||||||
|
if entry.Stream == api.LogStreamStderr {
|
||||||
|
fmt.Fprint(os.Stderr, output.String())
|
||||||
|
} else {
|
||||||
|
fmt.Print(output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printError prints an error entry (e.g., stalled stream warning).
|
||||||
|
func (f *logFormatter) printError(entry api.ServiceLogEntry) {
|
||||||
|
if entry.Metadata.ContainerID != "" {
|
||||||
|
msg := fmt.Sprintf("WARNING: log stream from %s[%s] on machine '%s'",
|
||||||
|
entry.Metadata.ServiceName,
|
||||||
|
stringid.TruncateID(entry.Metadata.ContainerID),
|
||||||
|
entry.Metadata.MachineName)
|
||||||
|
|
||||||
|
if errors.Is(entry.Err, api.ErrLogStreamStalled) {
|
||||||
|
msg += " stopped responding"
|
||||||
|
} else {
|
||||||
|
msg += fmt.Sprintf(": %v", entry.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow
|
||||||
|
fmt.Fprintln(os.Stderr, style.Render(msg))
|
||||||
|
} else {
|
||||||
|
msg := fmt.Sprintf("ERROR: %v", entry.Err)
|
||||||
|
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) // Bold bright red
|
||||||
|
fmt.Fprintln(os.Stderr, style.Render(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-20
@@ -4,34 +4,31 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand(groupID string) *cobra.Command {
|
||||||
// TODO(lhf): rename to context
|
|
||||||
var cluster string
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List services.",
|
Short: "List services.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return list(cmd.Context(), uncli, cluster)
|
return list(cmd.Context(), uncli)
|
||||||
},
|
},
|
||||||
|
GroupID: groupID,
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&cluster, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
func list(ctx context.Context, uncli *cli.CLI) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, clusterName)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -42,15 +39,15 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
|||||||
return fmt.Errorf("list services: %w", err)
|
return fmt.Errorf("list services: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceNames := make(map[string]struct{}, len(services))
|
// Sort services by name.
|
||||||
haveDuplicateNames := false
|
haveDuplicateNames := false
|
||||||
for _, svc := range services {
|
slices.SortFunc(services, func(a, b api.Service) int {
|
||||||
if _, exists := serviceNames[svc.Name]; exists {
|
if a.Name == b.Name {
|
||||||
haveDuplicateNames = true
|
haveDuplicateNames = true
|
||||||
break
|
return strings.Compare(a.ID, b.ID)
|
||||||
}
|
}
|
||||||
serviceNames[svc.Name] = struct{}{}
|
return strings.Compare(a.Name, b.Name)
|
||||||
}
|
})
|
||||||
|
|
||||||
// Print the list of services in a table format.
|
// Print the list of services in a table format.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
@@ -61,19 +58,29 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
|||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tENDPOINTS"); err != nil {
|
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tIMAGE\tENDPOINTS"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
for _, s := range services {
|
for _, s := range services {
|
||||||
endpointsSlice := s.Endpoints()
|
images := strings.Join(s.Images(), ", ")
|
||||||
endpoints := strings.Join(endpointsSlice, ", ")
|
endpoints := strings.Join(s.Endpoints(), ", ")
|
||||||
|
|
||||||
|
// 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if haveDuplicateNames {
|
if haveDuplicateNames {
|
||||||
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", s.Name, s.Mode, len(s.Containers), endpoints); err != nil {
|
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n",
|
||||||
|
s.Name, s.Mode, len(s.Containers), images, endpoints); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,31 +11,32 @@ import (
|
|||||||
|
|
||||||
type rmOptions struct {
|
type rmOptions struct {
|
||||||
services []string
|
services []string
|
||||||
cluster string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRmCommand() *cobra.Command {
|
func NewRmCommand(groupID string) *cobra.Command {
|
||||||
opts := rmOptions{}
|
opts := rmOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm SERVICE [SERVICE...]",
|
Use: "rm SERVICE [SERVICE...]",
|
||||||
Aliases: []string{"remove", "delete"},
|
Aliases: []string{"remove", "delete"},
|
||||||
Short: "Remove one or more services.",
|
Short: "Remove one or more services.",
|
||||||
Args: cobra.MinimumNArgs(1),
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
opts.services = args
|
opts.services = args
|
||||||
return rm(cmd.Context(), uncli, opts)
|
return rm(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: groupID,
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.cluster, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -50,5 +51,5 @@ func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
|||||||
}, uncli.ProgressOut(), "Removing service "+s)
|
}, uncli.ProgressOut(), "Removing service "+s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ func NewRootCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "service",
|
Use: "service",
|
||||||
Aliases: []string{"svc"},
|
Aliases: []string{"svc"},
|
||||||
Short: "Manage services in an Uncloud cluster.",
|
Short: "Manage services in the cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewInspectCommand(),
|
NewExecCommand(""),
|
||||||
NewListCommand(),
|
NewInspectCommand(""),
|
||||||
NewRmCommand(),
|
NewListCommand(""),
|
||||||
NewRunCommand(),
|
NewLogsCommand(""),
|
||||||
NewScaleCommand(),
|
NewRmCommand(""),
|
||||||
|
NewRunCommand(""),
|
||||||
|
NewScaleCommand(""),
|
||||||
|
NewStartCommand(""),
|
||||||
|
NewStopCommand(""),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-22
@@ -9,6 +9,7 @@ import (
|
|||||||
dockeropts "github.com/docker/cli/opts"
|
dockeropts "github.com/docker/cli/opts"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/docker/docker/daemon/names"
|
"github.com/docker/docker/daemon/names"
|
||||||
|
"github.com/docker/go-units"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type runOptions struct {
|
type runOptions struct {
|
||||||
|
caddyfile string
|
||||||
command []string
|
command []string
|
||||||
cpu dockeropts.NanoCPUs
|
cpu dockeropts.NanoCPUs
|
||||||
entrypoint string
|
entrypoint string
|
||||||
@@ -26,18 +28,18 @@ type runOptions struct {
|
|||||||
machines []string
|
machines []string
|
||||||
memory dockeropts.MemBytes
|
memory dockeropts.MemBytes
|
||||||
mode string
|
mode string
|
||||||
|
shmSize dockeropts.MemBytes
|
||||||
name string
|
name string
|
||||||
privileged bool
|
privileged bool
|
||||||
publish []string
|
publish []string
|
||||||
pull string
|
pull string
|
||||||
replicas uint
|
replicas uint
|
||||||
|
ulimits []string
|
||||||
user string
|
user string
|
||||||
volumes []string
|
volumes []string
|
||||||
|
|
||||||
cluster string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRunCommand() *cobra.Command {
|
func NewRunCommand(groupID string) *cobra.Command {
|
||||||
opts := runOptions{}
|
opts := runOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -55,8 +57,12 @@ func NewRunCommand() *cobra.Command {
|
|||||||
|
|
||||||
return run(cmd.Context(), uncli, opts)
|
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", "",
|
cmd.Flags().VarP(&opts.cpu, "cpu", "",
|
||||||
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+
|
"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.")
|
"0.5 for half a core or 2.25 for two and a quarter cores.")
|
||||||
@@ -73,8 +79,12 @@ func NewRunCommand() *cobra.Command {
|
|||||||
"Placement constraint by machine names, limiting which machines the service can run on. Can be specified "+
|
"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)")
|
"multiple times or as a comma-separated list of machine names. (default is any suitable machine)")
|
||||||
cmd.Flags().VarP(&opts.memory, "memory", "",
|
cmd.Flags().VarP(&opts.memory, "memory", "",
|
||||||
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix "+
|
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
|
||||||
"(b, k, m, g). Default unit is bytes if no suffix specified.\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)")
|
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
|
||||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
||||||
"Assign a name to the service. A random name is generated if not specified.")
|
"Assign a name to the service. A random name is generated if not specified.")
|
||||||
@@ -82,13 +92,14 @@ func NewRunCommand() *cobra.Command {
|
|||||||
"Give extended privileges to service containers. This is a security risk and should be used with caution.")
|
"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,
|
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"+
|
"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"+
|
"Format: [hostname:]container_port[/protocol] or [host_ip:]host_port:container_port[/protocol]@host\n"+
|
||||||
"Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified\n"+
|
"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"+
|
"and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+
|
||||||
"Examples:\n"+
|
"Examples:\n"+
|
||||||
" -p 8080/https Publish port 8080 as HTTPS via load balancer with default service-name.cluster-domain hostname\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 load balancer with custom hostname\n"+
|
" -p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname\n"+
|
||||||
" -p 9000:8080 Publish port 8080 as TCP port 9000 via load balancer\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")
|
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
|
||||||
cmd.Flags().StringVar(&opts.pull, "pull", api.PullPolicyMissing,
|
cmd.Flags().StringVar(&opts.pull, "pull", api.PullPolicyMissing,
|
||||||
fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').",
|
fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').",
|
||||||
@@ -98,6 +109,12 @@ func NewRunCommand() *cobra.Command {
|
|||||||
cmd.Flags().StringVarP(&opts.user, "user", "u", "",
|
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"+
|
"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.")
|
"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,
|
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"+
|
"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"+
|
"the volume is located. Can be specified multiple times.\n"+
|
||||||
@@ -107,11 +124,6 @@ func NewRunCommand() *cobra.Command {
|
|||||||
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads 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")
|
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.cluster, "context", "c", "",
|
|
||||||
"Name of the cluster context to run the service in. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +133,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -160,6 +172,15 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
|||||||
func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
||||||
var spec api.ServiceSpec
|
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)
|
env, err := parseEnv(opts.env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spec, err
|
return spec, err
|
||||||
@@ -186,13 +207,18 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
|||||||
ports[i] = port
|
ports[i] = port
|
||||||
}
|
}
|
||||||
|
|
||||||
volumes, mounts, err := parseVolumeFlags(opts.volumes)
|
placement := api.Placement{
|
||||||
|
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||||
|
}
|
||||||
|
|
||||||
|
ulimits, err := parseUlimits(opts.ulimits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spec, err
|
return spec, err
|
||||||
}
|
}
|
||||||
|
|
||||||
placement := api.Placement{
|
volumes, mounts, err := parseVolumeFlags(opts.volumes)
|
||||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
if err != nil {
|
||||||
|
return spec, err
|
||||||
}
|
}
|
||||||
|
|
||||||
spec = api.ServiceSpec{
|
spec = api.ServiceSpec{
|
||||||
@@ -203,8 +229,10 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
|||||||
Privileged: opts.privileged,
|
Privileged: opts.privileged,
|
||||||
PullPolicy: opts.pull,
|
PullPolicy: opts.pull,
|
||||||
Resources: api.ContainerResources{
|
Resources: api.ContainerResources{
|
||||||
CPU: opts.cpu.Value(),
|
CPU: opts.cpu.Value(),
|
||||||
Memory: opts.memory.Value(),
|
Memory: opts.memory.Value(),
|
||||||
|
SharedMemory: opts.shmSize.Value(),
|
||||||
|
Ulimits: ulimits,
|
||||||
},
|
},
|
||||||
User: opts.user,
|
User: opts.user,
|
||||||
VolumeMounts: mounts,
|
VolumeMounts: mounts,
|
||||||
@@ -217,6 +245,12 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
|||||||
Volumes: volumes,
|
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.
|
// Overwrite the default ENTRYPOINT of the image or reset it if an empty string is passed.
|
||||||
if opts.entrypoint != "" {
|
if opts.entrypoint != "" {
|
||||||
spec.Container.Entrypoint = []string{opts.entrypoint}
|
spec.Container.Entrypoint = []string{opts.entrypoint}
|
||||||
@@ -288,6 +322,24 @@ func parseVolumeFlags(volumes []string) ([]api.VolumeSpec, []api.VolumeMount, er
|
|||||||
return specs, mounts, nil
|
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) {
|
func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error) {
|
||||||
var spec api.VolumeSpec
|
var spec api.VolumeSpec
|
||||||
var mount api.VolumeMount
|
var mount api.VolumeMount
|
||||||
@@ -306,8 +358,8 @@ func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error
|
|||||||
volumeNoCopy := false
|
volumeNoCopy := false
|
||||||
|
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
opts := strings.Split(parts[2], ",")
|
opts := strings.SplitSeq(parts[2], ",")
|
||||||
for _, opt := range opts {
|
for opt := range opts {
|
||||||
switch opt {
|
switch opt {
|
||||||
case "ro", "readonly":
|
case "ro", "readonly":
|
||||||
mount.ReadOnly = true
|
mount.ReadOnly = true
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -15,17 +19,19 @@ import (
|
|||||||
type scaleOptions struct {
|
type scaleOptions struct {
|
||||||
service string
|
service string
|
||||||
replicas uint
|
replicas uint
|
||||||
cluster string
|
yes bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewScaleCommand() *cobra.Command {
|
func NewScaleCommand(groupID string) *cobra.Command {
|
||||||
opts := scaleOptions{}
|
opts := scaleOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "scale SERVICE REPLICAS",
|
Use: "scale SERVICE REPLICAS",
|
||||||
Short: "Scale a replicated service by changing the number of replicas.",
|
Short: "Scale a replicated service by changing the number of replicas.",
|
||||||
Long: "Scale a replicated service by changing the number of replicas. Scaling down requires confirmation.",
|
Long: "Scale a replicated service by changing the number of replicas.",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||||
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
opts.service = args[0]
|
opts.service = args[0]
|
||||||
@@ -37,12 +43,12 @@ func NewScaleCommand() *cobra.Command {
|
|||||||
|
|
||||||
return scale(cmd.Context(), uncli, opts)
|
return scale(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: groupID,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
&opts.cluster, "context", "c", "",
|
"Auto-confirm scaling plan. Should be explicitly set when running non-interactively,\n"+
|
||||||
"Name of the cluster context. (default is the current context)",
|
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -76,7 +82,8 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
currentReplicas := uint(len(svc.Containers))
|
currentReplicas := uint(len(svc.Containers))
|
||||||
|
|
||||||
if currentReplicas == opts.replicas {
|
if currentReplicas == opts.replicas {
|
||||||
fmt.Printf("Service '%s' already has %d replicas. No changes required.\n", svc.Name, currentReplicas)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,23 +98,51 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(plan.Operations) == 0 {
|
if len(plan.Operations) == 0 {
|
||||||
fmt.Printf("Service '%s' is already scaled to %d replicas.\n", svc.Name, opts.replicas)
|
fmt.Printf("Service %s is already scaled to %d replicas.\n", tui.NameStyle.Render(svc.Name), opts.replicas)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.replicas < currentReplicas {
|
fmt.Println(tui.Bold.Underline(true).Render("Scaling plan"))
|
||||||
// Initialise a machine and container name resolver to properly format the plan output.
|
fmt.Println()
|
||||||
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
|
||||||
if err != nil {
|
directConn := uncli.DirectConnection()
|
||||||
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
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.IsStdinTerminal() {
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas)
|
title := "Proceed with scaling?"
|
||||||
fmt.Println(plan.Format(resolver))
|
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
|
||||||
fmt.Println()
|
// 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("?")
|
||||||
|
}
|
||||||
|
|
||||||
// Ask for confirmation before scaling down as it may cause data loss.
|
confirmed, err := tui.Confirm(title)
|
||||||
confirmed, err := confirm()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("confirm scaling: %w", err)
|
return fmt.Errorf("confirm scaling: %w", err)
|
||||||
}
|
}
|
||||||
@@ -117,7 +152,8 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas)
|
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 {
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
if _, err = deployment.Run(ctx); err != nil {
|
if _, err = deployment.Run(ctx); err != nil {
|
||||||
return fmt.Errorf("deploy service: %w", err)
|
return fmt.Errorf("deploy service: %w", err)
|
||||||
@@ -130,23 +166,3 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirm() (bool, error) {
|
|
||||||
var confirmed bool
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title(
|
|
||||||
"Do you want to continue?",
|
|
||||||
).
|
|
||||||
Affirmative("Yes!").
|
|
||||||
Negative("No").
|
|
||||||
Value(&confirmed),
|
|
||||||
),
|
|
||||||
).WithAccessible(true)
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return confirmed, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//nolint:dupl
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
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,68 @@
|
|||||||
|
//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/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,
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"charm.land/huh/v2"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
@@ -19,14 +19,13 @@ type createOptions struct {
|
|||||||
driverOpts []string
|
driverOpts []string
|
||||||
labels []string
|
labels []string
|
||||||
machine string
|
machine string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCreateCommand() *cobra.Command {
|
func NewCreateCommand() *cobra.Command {
|
||||||
opts := createOptions{}
|
opts := createOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create [FLAGS] VOLUME_NAME",
|
Use: "create VOLUME_NAME",
|
||||||
Short: "Create a volume on a specific machine.",
|
Short: "Create a volume on a specific machine.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -50,14 +49,12 @@ func NewCreateCommand() *cobra.Command {
|
|||||||
"Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.")
|
"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", "",
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
"Name or ID of the machine to create the volume on.")
|
"Name or ID of the machine to create the volume on.")
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
|
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ import (
|
|||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
machine string
|
machine string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInspectCommand() *cobra.Command {
|
func NewInspectCommand() *cobra.Command {
|
||||||
opts := inspectOptions{}
|
opts := inspectOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "inspect [FLAGS] VOLUME_NAME",
|
Use: "inspect VOLUME_NAME",
|
||||||
Short: "Display detailed information on a volume.",
|
Short: "Display detailed information on a volume.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -32,14 +31,12 @@ func NewInspectCommand() *cobra.Command {
|
|||||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
"Name or ID of the machine where the volume is located. "+
|
"Name or ID of the machine where the volume is located. "+
|
||||||
"If not specified, the volume will be searched across all machines.")
|
"If not specified, the volume will be searched across all machines.")
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
|
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,13 @@ import (
|
|||||||
type listOptions struct {
|
type listOptions struct {
|
||||||
machines []string
|
machines []string
|
||||||
quiet bool
|
quiet bool
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
opts := listOptions{}
|
opts := listOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls [FLAGS]",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List volumes across all machines in the cluster.",
|
Short: "List volumes across all machines in the cluster.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -38,14 +37,11 @@ func NewListCommand() *cobra.Command {
|
|||||||
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
|
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
|
||||||
"Only display volume names.")
|
"Only display volume names.")
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -14,14 +15,13 @@ type removeOptions struct {
|
|||||||
force bool
|
force bool
|
||||||
machines []string
|
machines []string
|
||||||
yes bool
|
yes bool
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRemoveCommand() *cobra.Command {
|
func NewRemoveCommand() *cobra.Command {
|
||||||
opts := removeOptions{}
|
opts := removeOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm [FLAGS] VOLUME_NAME [VOLUME_NAME...]",
|
Use: "rm VOLUME_NAME [VOLUME_NAME...]",
|
||||||
Aliases: []string{"remove", "delete"},
|
Aliases: []string{"remove", "delete"},
|
||||||
Short: "Remove one or more volumes.",
|
Short: "Remove one or more volumes.",
|
||||||
Long: "Remove one or more volumes. You cannot remove a volume that is in use by a container.",
|
Long: "Remove one or more volumes. You cannot remove a volume that is in use by a container.",
|
||||||
@@ -41,14 +41,11 @@ func NewRemoveCommand() *cobra.Command {
|
|||||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
"Do not prompt for confirmation before removing the volume(s).")
|
"Do not prompt for confirmation before removing the volume(s).")
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
|
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -83,7 +80,7 @@ func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOpti
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
confirmed, err := cli.Confirm()
|
confirmed, err := tui.Confirm("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("confirm removal: %w", err)
|
return fmt.Errorf("confirm removal: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
func NewRootCommand() *cobra.Command {
|
func NewRootCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "volume",
|
Use: "volume",
|
||||||
Short: "Manage volumes in an Uncloud cluster.",
|
Short: "Manage volumes in the cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewCreateCommand(),
|
NewCreateCommand(),
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package wg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"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)")
|
||||||
|
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, _, err = client.ProxyMachinesContext(ctx, []string{opts.machine})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
machinesNamesByPublicKey := make(map[string]string)
|
||||||
|
for _, m := range machines {
|
||||||
|
publicKey := wgtypes.Key(m.Machine.Network.PublicKey).String()
|
||||||
|
machinesNamesByPublicKey[publicKey] = m.Machine.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the machine's name for more descriptive output
|
||||||
|
inspectResp, err := client.MachineClient.InspectMachine(ctx, nil)
|
||||||
|
if err == nil {
|
||||||
|
fmt.Printf("Machine name: %s\n", inspectResp.Machines[0].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
|
||||||
|
}
|
||||||
|
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
|
if _, err = fmt.Fprintln(tw, "PEER\tPUBLIC KEY\tENDPOINT\tHANDSHAKE\tRECEIVED\tSENT\tALLOWED IPS"); err != nil {
|
||||||
|
return fmt.Errorf("write header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, peer := range resp.Peers {
|
||||||
|
machineName, ok := machinesNamesByPublicKey[wgtypes.Key(peer.PublicKey).String()]
|
||||||
|
if !ok {
|
||||||
|
machineName = "(unknown)"
|
||||||
|
}
|
||||||
|
|
||||||
|
lastHandshake := ""
|
||||||
|
if peer.LastHandshakeTime != nil {
|
||||||
|
lastHandshake = time.Since(peer.LastHandshakeTime.AsTime()).Round(time.Second).String() + " ago"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(
|
||||||
|
tw,
|
||||||
|
"%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
machineName,
|
||||||
|
wgtypes.Key(peer.PublicKey).String(),
|
||||||
|
peer.Endpoint,
|
||||||
|
lastHandshake,
|
||||||
|
units.HumanSize(float64(peer.ReceiveBytes)),
|
||||||
|
units.HumanSize(float64(peer.TransmitBytes)),
|
||||||
|
strings.Join(peer.AllowedIps, ", "),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write row: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tw.Flush()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ func main() {
|
|||||||
"Directory for storing persistent machine state")
|
"Directory for storing persistent machine state")
|
||||||
_ = cmd.MarkFlagDirname("data-dir")
|
_ = cmd.MarkFlagDirname("data-dir")
|
||||||
|
|
||||||
|
// Add dial-stdio subcommand.
|
||||||
|
cmd.AddCommand(newDialStdioCommand())
|
||||||
|
|
||||||
// ctx is canceled when the daemon command is interrupted.
|
// ctx is canceled when the daemon command is interrupted.
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
@@ -55,5 +58,4 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
cobra.CheckErr(cmd.ExecuteContext(ctx))
|
cobra.CheckErr(cmd.ExecuteContext(ctx))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/hashicorp/serf/serf"
|
|
||||||
crdt "github.com/ipfs/go-ds-crdt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/serf/serf"
|
||||||
|
crdt "github.com/ipfs/go-ds-crdt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Implements the Broadcaster interface.
|
// Implements the Broadcaster interface.
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/dgraph-io/badger/v3"
|
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func customTimeEncoder(t time.Time) string {
|
func customTimeEncoder(t time.Time) string {
|
||||||
|
|||||||
+14
-13
@@ -2,9 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/ipfs/go-log/v2"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/ipfs/go-log/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ipfsLogger is an slog logger that implements the IPFS go-log StandardLogger interface.
|
// ipfsLogger is an slog logger that implements the IPFS go-log StandardLogger interface.
|
||||||
@@ -16,57 +17,57 @@ func newIPFSLogger(l *slog.Logger) *ipfsLogger {
|
|||||||
return &ipfsLogger{log: *l}
|
return &ipfsLogger{log: *l}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Debug(args ...interface{}) {
|
func (l *ipfsLogger) Debug(args ...any) {
|
||||||
l.log.Debug(fmt.Sprint(args...))
|
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...))
|
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...))
|
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...))
|
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...))
|
l.log.Error(fmt.Sprint(args...))
|
||||||
os.Exit(1)
|
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...))
|
l.log.Error(fmt.Sprintf(format, args...))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Info(args ...interface{}) {
|
func (l *ipfsLogger) Info(args ...any) {
|
||||||
l.log.Info(fmt.Sprint(args...))
|
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...))
|
l.log.Info(fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Panic(args ...interface{}) {
|
func (l *ipfsLogger) Panic(args ...any) {
|
||||||
msg := fmt.Sprint(args...)
|
msg := fmt.Sprint(args...)
|
||||||
l.log.Error(msg)
|
l.log.Error(msg)
|
||||||
panic(msg)
|
panic(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Panicf(format string, args ...interface{}) {
|
func (l *ipfsLogger) Panicf(format string, args ...any) {
|
||||||
msg := fmt.Sprintf(format, args...)
|
msg := fmt.Sprintf(format, args...)
|
||||||
l.log.Error(msg)
|
l.log.Error(msg)
|
||||||
panic(msg)
|
panic(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipfsLogger) Warn(args ...interface{}) {
|
func (l *ipfsLogger) Warn(args ...any) {
|
||||||
l.log.Warn(fmt.Sprint(args...))
|
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...))
|
l.log.Warn(fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/docker/libnetwork/networkdb"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/libnetwork/networkdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
+13
-12
@@ -4,6 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/memberlist"
|
"github.com/hashicorp/memberlist"
|
||||||
"github.com/hashicorp/serf/cmd/serf/command/agent"
|
"github.com/hashicorp/serf/cmd/serf/command/agent"
|
||||||
"github.com/hashicorp/serf/serf"
|
"github.com/hashicorp/serf/serf"
|
||||||
@@ -11,12 +18,6 @@ import (
|
|||||||
badger "github.com/ipfs/go-ds-badger3"
|
badger "github.com/ipfs/go-ds-badger3"
|
||||||
crdt "github.com/ipfs/go-ds-crdt"
|
crdt "github.com/ipfs/go-ds-crdt"
|
||||||
"github.com/lmittmann/tint"
|
"github.com/lmittmann/tint"
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func createSerfAgentConfig(name, bindAddr, rpcAddr, profile string) *agent.Config {
|
func createSerfAgentConfig(name, bindAddr, rpcAddr, profile string) *agent.Config {
|
||||||
@@ -49,9 +50,9 @@ func createSerfAgent(config *agent.Config) (*agent.Agent, error) {
|
|||||||
|
|
||||||
serfConfig.MemberlistConfig.BindAddr = bindIP
|
serfConfig.MemberlistConfig.BindAddr = bindIP
|
||||||
serfConfig.MemberlistConfig.BindPort = bindPort
|
serfConfig.MemberlistConfig.BindPort = bindPort
|
||||||
//serfConfig.MemberlistConfig.AdvertiseAddr = advertiseIP
|
// serfConfig.MemberlistConfig.AdvertiseAddr = advertiseIP
|
||||||
//serfConfig.MemberlistConfig.AdvertisePort = advertisePort
|
// serfConfig.MemberlistConfig.AdvertisePort = advertisePort
|
||||||
//serfConfig.MemberlistConfig.SecretKey = encryptKey
|
// serfConfig.MemberlistConfig.SecretKey = encryptKey
|
||||||
serfConfig.NodeName = config.NodeName
|
serfConfig.NodeName = config.NodeName
|
||||||
serfConfig.Tags = config.Tags
|
serfConfig.Tags = config.Tags
|
||||||
serfConfig.SnapshotPath = config.SnapshotPath
|
serfConfig.SnapshotPath = config.SnapshotPath
|
||||||
@@ -129,7 +130,7 @@ func main() {
|
|||||||
logger := slog.New(tint.NewHandler(os.Stdout, &tint.Options{
|
logger := slog.New(tint.NewHandler(os.Stdout, &tint.Options{
|
||||||
AddSource: true,
|
AddSource: true,
|
||||||
Level: slog.LevelDebug,
|
Level: slog.LevelDebug,
|
||||||
//Level: slog.LevelInfo,
|
// Level: slog.LevelInfo,
|
||||||
TimeFormat: time.RFC3339Nano,
|
TimeFormat: time.RFC3339Nano,
|
||||||
}))
|
}))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
@@ -164,7 +165,7 @@ func main() {
|
|||||||
|
|
||||||
opts := crdt.DefaultOptions()
|
opts := crdt.DefaultOptions()
|
||||||
opts.Logger = newIPFSLogger(logger)
|
opts.Logger = newIPFSLogger(logger)
|
||||||
//opts.MultiHeadProcessing = true
|
// opts.MultiHeadProcessing = true
|
||||||
// TODO: debug why the heads count may grow on the receiving side if the event backlog is huge and the processing
|
// TODO: debug why the heads count may grow on the receiving side if the event backlog is huge and the processing
|
||||||
// is slow.
|
// is slow.
|
||||||
store, err := crdt.New(localStore, ds.NewKey("/"), syncer, broadcaster, opts)
|
store, err := crdt.New(localStore, ds.NewKey("/"), syncer, broadcaster, opts)
|
||||||
@@ -172,7 +173,7 @@ func main() {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
//ticker := time.NewTicker(10 * time.Millisecond)
|
// ticker := time.NewTicker(10 * time.Millisecond)
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
"github.com/hashicorp/serf/serf"
|
"github.com/hashicorp/serf/serf"
|
||||||
"github.com/ipfs/boxo/datastore/dshelp"
|
"github.com/ipfs/boxo/datastore/dshelp"
|
||||||
dag "github.com/ipfs/boxo/ipld/merkledag"
|
dag "github.com/ipfs/boxo/ipld/merkledag"
|
||||||
"github.com/ipfs/go-cid"
|
"github.com/ipfs/go-cid"
|
||||||
ds "github.com/ipfs/go-datastore"
|
ds "github.com/ipfs/go-datastore"
|
||||||
ipld "github.com/ipfs/go-ipld-format"
|
ipld "github.com/ipfs/go-ipld-format"
|
||||||
"log/slog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Implements the DAGService interface.
|
// Implements the DAGService interface.
|
||||||
// TODO: implement SessionDAGService to optimize node fetching.
|
// TODO: implement SessionDAGService to optimize node fetching.
|
||||||
// TOOD: persistentSerfDAG?
|
// TODO: persistentSerfDAG?
|
||||||
type dagSyncer struct {
|
type dagSyncer struct {
|
||||||
// Persistent storage for the nodes.
|
// Persistent storage for the nodes.
|
||||||
store ds.Datastore
|
store ds.Datastore
|
||||||
@@ -50,8 +51,7 @@ func (d *dagSyncer) Get(ctx context.Context, cid cid.Cid) (ipld.Node, error) {
|
|||||||
}
|
}
|
||||||
slog.Debug("Queried node from peers", "cid", cid, "deadline", query.Deadline())
|
slog.Debug("Queried node from peers", "cid", cid, "deadline", query.Deadline())
|
||||||
|
|
||||||
responded := false
|
for {
|
||||||
for !responded {
|
|
||||||
select {
|
select {
|
||||||
case resp, ok := <-query.ResponseCh():
|
case resp, ok := <-query.ResponseCh():
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -63,7 +63,6 @@ func (d *dagSyncer) Get(ctx context.Context, cid cid.Cid) (ipld.Node, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Debug("Received node from peer", "cid", cid, "peer", resp.From)
|
slog.Debug("Received node from peer", "cid", cid, "peer", resp.From)
|
||||||
responded = true
|
|
||||||
query.Close()
|
query.Close()
|
||||||
|
|
||||||
node, err = nodeFromBytes(resp.Payload)
|
node, err = nodeFromBytes(resp.Payload)
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import (
|
|||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/siderolabs/discovery-api/api/v1alpha1/client/pb"
|
"github.com/siderolabs/discovery-api/api/v1alpha1/client/pb"
|
||||||
discovery "github.com/siderolabs/discovery-client/pkg/client"
|
discovery "github.com/siderolabs/discovery-client/pkg/client"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"net/netip"
|
|
||||||
"time"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
module github.com/psviderski/uncloud
|
module github.com/psviderski/uncloud
|
||||||
|
|
||||||
go 1.23.0
|
go 1.26
|
||||||
|
|
||||||
toolchain go1.23.2
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.4.0
|
charm.land/bubbles/v2 v2.0.0
|
||||||
|
charm.land/bubbletea/v2 v2.0.2
|
||||||
|
charm.land/huh/v2 v2.0.1
|
||||||
|
charm.land/lipgloss/v2 v2.0.1
|
||||||
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/Masterminds/semver v1.5.0
|
github.com/Masterminds/semver v1.5.0
|
||||||
github.com/Masterminds/squirrel v1.5.4
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
|
github.com/alecthomas/chroma/v2 v2.20.0
|
||||||
github.com/caddyserver/caddy/v2 v2.8.4
|
github.com/caddyserver/caddy/v2 v2.8.4
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
github.com/charmbracelet/huh v0.6.0
|
github.com/charmbracelet/colorprofile v0.4.2
|
||||||
github.com/charmbracelet/lipgloss v0.13.0
|
github.com/compose-spec/compose-go/v2 v2.9.0
|
||||||
github.com/compose-spec/compose-go/v2 v2.4.5
|
github.com/containerd/errdefs v1.0.0
|
||||||
|
github.com/containerd/platforms v1.0.0-rc.1
|
||||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||||
github.com/deckarep/golang-set/v2 v2.8.0
|
github.com/deckarep/golang-set/v2 v2.8.0
|
||||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v27.5.0+incompatible
|
github.com/docker/cli v28.5.0+incompatible
|
||||||
github.com/docker/compose/v2 v2.31.0
|
github.com/docker/compose/v2 v2.40.0
|
||||||
github.com/docker/docker v27.4.0-rc.2+incompatible
|
github.com/docker/docker v28.5.0+incompatible
|
||||||
github.com/docker/go-connections v0.5.0
|
github.com/docker/go-connections v0.6.0
|
||||||
github.com/docker/go-units v0.5.0
|
github.com/docker/go-units v0.5.0
|
||||||
github.com/goccy/go-yaml v1.17.1
|
github.com/goccy/go-yaml v1.17.1
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
@@ -37,114 +41,158 @@ require (
|
|||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/lmittmann/tint v1.0.5
|
github.com/lmittmann/tint v1.0.5
|
||||||
github.com/miekg/dns v1.1.65
|
github.com/miekg/dns v1.1.65
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
|
github.com/moby/term v0.5.2
|
||||||
github.com/opencontainers/go-digest v1.0.0
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/opencontainers/image-spec v1.1.0
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
|
github.com/psviderski/unregistry v0.4.1
|
||||||
github.com/siderolabs/discovery-api v0.1.4
|
github.com/siderolabs/discovery-api v0.1.4
|
||||||
github.com/siderolabs/discovery-client v0.1.9
|
github.com/siderolabs/discovery-client v0.1.9
|
||||||
github.com/siderolabs/grpc-proxy v0.5.1
|
github.com/siderolabs/grpc-proxy v0.5.1
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.1
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||||
golang.org/x/crypto v0.33.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/net v0.35.0
|
golang.org/x/net v0.43.0
|
||||||
golang.org/x/sync v0.11.0
|
golang.org/x/sync v0.19.0
|
||||||
golang.org/x/sys v0.31.0
|
golang.org/x/sys v0.42.0
|
||||||
|
golang.org/x/term v0.34.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a
|
||||||
google.golang.org/grpc v1.68.1
|
google.golang.org/grpc v1.74.2
|
||||||
google.golang.org/protobuf v1.36.3
|
google.golang.org/protobuf v1.36.9
|
||||||
modernc.org/sqlite v1.36.3
|
modernc.org/sqlite v1.36.3
|
||||||
|
tags.cncf.io/container-device-interface v1.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/kms v1.19.0 // indirect
|
||||||
|
cloud.google.com/go/longrunning v0.6.0 // indirect
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
|
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
||||||
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
|
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
|
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect
|
||||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||||
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/OneOfOne/xxhash v1.2.8 // indirect
|
github.com/Microsoft/hcsshim v0.13.0 // indirect
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
|
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
||||||
github.com/armon/go-metrics v0.4.1 // indirect
|
github.com/armon/go-metrics v0.4.1 // indirect
|
||||||
github.com/armon/go-radix v1.0.0 // indirect
|
github.com/armon/go-radix v1.0.0 // indirect
|
||||||
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
|
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
|
||||||
|
github.com/aws/smithy-go v1.20.3 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bgentry/speakeasy v0.2.0 // indirect
|
github.com/bgentry/speakeasy v0.2.0 // indirect
|
||||||
github.com/buger/goterm v1.0.4 // indirect
|
github.com/buger/goterm v1.0.4 // indirect
|
||||||
github.com/caddyserver/certmagic v0.21.4 // indirect
|
github.com/caddyserver/certmagic v0.21.4 // indirect
|
||||||
github.com/caddyserver/zerossl v0.1.3 // indirect
|
github.com/caddyserver/zerossl v0.1.3 // indirect
|
||||||
github.com/catppuccin/go v0.2.0 // indirect
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
github.com/cespare/xxhash v1.1.0 // indirect
|
github.com/cespare/xxhash v1.1.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/bubbles v0.20.0 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.1.1 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.3.2 // indirect
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
github.com/chzyer/readline v1.5.1 // indirect
|
github.com/chzyer/readline v1.5.1 // indirect
|
||||||
github.com/cloudflare/cfssl v1.6.4 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/containerd/console v1.0.4 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/containerd/containerd v1.7.24 // indirect
|
github.com/containerd/cgroups/v3 v3.0.5 // indirect
|
||||||
github.com/containerd/containerd/api v1.7.19 // indirect
|
github.com/containerd/console v1.0.5 // indirect
|
||||||
github.com/containerd/continuity v0.4.4 // indirect
|
github.com/containerd/containerd/api v1.9.0 // indirect
|
||||||
github.com/containerd/errdefs v0.3.0 // indirect
|
github.com/containerd/containerd/v2 v2.1.4 // indirect
|
||||||
|
github.com/containerd/continuity v0.4.5 // indirect
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
|
github.com/containerd/fifo v1.1.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/containerd/platforms v0.2.1 // indirect
|
github.com/containerd/plugin v1.0.0 // indirect
|
||||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||||
github.com/containerd/ttrpc v1.2.5 // indirect
|
github.com/containerd/ttrpc v1.2.7 // indirect
|
||||||
github.com/containerd/typeurl/v2 v2.2.0 // indirect
|
github.com/containerd/typeurl/v2 v2.2.3 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||||
github.com/dgraph-io/badger v1.6.2 // indirect
|
github.com/dgraph-io/badger v1.6.2 // indirect
|
||||||
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
||||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
||||||
github.com/docker/buildx v0.18.0 // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/distribution/distribution/v3 v3.0.0 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
|
github.com/docker/buildx v0.29.1 // indirect
|
||||||
|
github.com/docker/cli-docs-tool v0.10.0 // indirect
|
||||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||||
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
||||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
||||||
github.com/docker/go-metrics v0.0.1 // indirect
|
github.com/docker/go-metrics v0.0.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect
|
||||||
github.com/fatih/color v1.17.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||||
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/fsnotify/fsevents v0.2.0 // indirect
|
||||||
github.com/fvbommel/sortorder v1.1.0 // indirect
|
github.com/fvbommel/sortorder v1.1.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
||||||
github.com/go-kit/kit v0.13.0 // indirect
|
github.com/go-kit/kit v0.13.0 // indirect
|
||||||
github.com/go-kit/log v0.2.1 // indirect
|
github.com/go-kit/log v0.2.1 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/gofrs/flock v0.12.1 // indirect
|
github.com/gofrs/flock v0.12.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/glog v1.2.2 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||||
|
github.com/golang/glog v1.2.5 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/google/btree v1.1.2 // indirect
|
github.com/google/btree v1.1.2 // indirect
|
||||||
github.com/google/cel-go v0.20.1 // indirect
|
github.com/google/cel-go v0.20.1 // indirect
|
||||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
github.com/google/gnostic-models v0.6.8 // indirect
|
||||||
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/handlers v1.5.2 // indirect
|
||||||
github.com/gorilla/mux v1.8.1 // indirect
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
|
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
|
||||||
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
||||||
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
|
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
@@ -152,13 +200,15 @@ require (
|
|||||||
github.com/hashicorp/go-syslog v1.0.0 // indirect
|
github.com/hashicorp/go-syslog v1.0.0 // indirect
|
||||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/arc/v2 v2.0.7 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/hashicorp/logutils v1.0.0 // indirect
|
github.com/hashicorp/logutils v1.0.0 // indirect
|
||||||
github.com/hashicorp/mdns v1.0.5 // indirect
|
github.com/hashicorp/mdns v1.0.5 // indirect
|
||||||
github.com/huandu/xstrings v1.5.0 // indirect
|
github.com/huandu/xstrings v1.5.0 // indirect
|
||||||
github.com/imdario/mergo v0.3.16 // indirect
|
github.com/imdario/mergo v0.3.16 // indirect
|
||||||
github.com/in-toto/in-toto-golang v0.5.0 // indirect
|
github.com/in-toto/in-toto-golang v0.9.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect
|
||||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||||
github.com/ipfs/go-block-format v0.2.0 // indirect
|
github.com/ipfs/go-block-format v0.2.0 // indirect
|
||||||
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
|
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
|
||||||
@@ -176,8 +226,12 @@ require (
|
|||||||
github.com/jackc/pgtype v1.14.0 // indirect
|
github.com/jackc/pgtype v1.14.0 // indirect
|
||||||
github.com/jackc/pgx/v4 v4.18.3 // indirect
|
github.com/jackc/pgx/v4 v4.18.3 // indirect
|
||||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||||
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/josharian/native v1.1.0 // indirect
|
github.com/josharian/native v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.17.11 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||||
@@ -186,12 +240,12 @@ require (
|
|||||||
github.com/libp2p/go-libp2p v0.35.4 // indirect
|
github.com/libp2p/go-libp2p v0.35.4 // indirect
|
||||||
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
|
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
|
||||||
github.com/libp2p/go-msgio v0.3.0 // indirect
|
github.com/libp2p/go-msgio v0.3.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/manifoldco/promptui v0.9.0 // indirect
|
github.com/manifoldco/promptui v0.9.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
|
||||||
github.com/mattn/go-shellwords v1.0.12 // indirect
|
github.com/mattn/go-shellwords v1.0.12 // indirect
|
||||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||||
@@ -205,20 +259,26 @@ require (
|
|||||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/moby/buildkit v0.17.2 // indirect
|
github.com/moby/buildkit v0.25.0 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
|
github.com/moby/go-archive v0.1.0 // indirect
|
||||||
github.com/moby/locker v1.0.1 // indirect
|
github.com/moby/locker v1.0.1 // indirect
|
||||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
|
github.com/moby/spdystream v0.5.0 // indirect
|
||||||
|
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||||
|
github.com/moby/sys/capability v0.4.0 // indirect
|
||||||
|
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
||||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||||
github.com/moby/sys/signal v0.7.1 // indirect
|
github.com/moby/sys/signal v0.7.1 // indirect
|
||||||
github.com/moby/term v0.5.0 // indirect
|
github.com/moby/sys/symlink v0.3.0 // indirect
|
||||||
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/morikuni/aec v1.0.0 // indirect
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
|
|
||||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||||
github.com/multiformats/go-base36 v0.2.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 v0.13.0 // indirect
|
||||||
@@ -229,93 +289,130 @@ require (
|
|||||||
github.com/multiformats/go-multistream v0.5.0 // indirect
|
github.com/multiformats/go-multistream v0.5.0 // indirect
|
||||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||||
|
github.com/opencontainers/runtime-spec v1.2.1 // indirect
|
||||||
|
github.com/opencontainers/selinux v1.12.0 // indirect
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
github.com/pires/go-proxyproto v0.7.0 // indirect
|
github.com/pires/go-proxyproto v0.7.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/polydawn/refmt v0.89.0 // indirect
|
github.com/polydawn/refmt v0.89.0 // indirect
|
||||||
github.com/posener/complete v1.2.3 // indirect
|
github.com/posener/complete v1.2.3 // indirect
|
||||||
github.com/prometheus/client_golang v1.20.5 // indirect
|
github.com/prometheus/client_golang v1.22.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/prometheus/common v0.61.0 // indirect
|
github.com/prometheus/common v0.62.0 // indirect
|
||||||
github.com/prometheus/procfs v0.15.1 // indirect
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||||
|
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
|
||||||
|
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
|
||||||
|
github.com/redis/go-redis/v9 v9.7.3 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/xid v1.5.0 // indirect
|
github.com/rs/xid v1.5.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
|
||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
||||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
github.com/secure-systems-lab/go-securesystemslib v0.6.0 // indirect
|
||||||
|
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
|
||||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/siderolabs/gen v0.4.8 // indirect
|
github.com/siderolabs/gen v0.4.8 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
|
||||||
github.com/slackhq/nebula v1.6.1 // indirect
|
github.com/slackhq/nebula v1.6.1 // indirect
|
||||||
github.com/smallstep/certificates v0.26.1 // indirect
|
github.com/smallstep/certificates v0.26.1 // indirect
|
||||||
github.com/smallstep/nosql v0.6.1 // indirect
|
github.com/smallstep/nosql v0.6.1 // indirect
|
||||||
github.com/smallstep/pkcs7 v0.0.0-20231024181729-3b98ecc1ca81 // indirect
|
github.com/smallstep/pkcs7 v0.1.1 // indirect
|
||||||
github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
|
github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
|
||||||
github.com/smallstep/truststore v0.13.0 // indirect
|
github.com/smallstep/truststore v0.13.0 // indirect
|
||||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/spf13/cast v1.7.0 // indirect
|
github.com/spf13/cast v1.7.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
||||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||||
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
|
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
|
||||||
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c // indirect
|
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect
|
||||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
|
github.com/tonistiigi/fsutil v0.0.0-20250605211040-586307ad452f // indirect
|
||||||
|
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect
|
||||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
||||||
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
|
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
|
||||||
github.com/urfave/cli v1.22.16 // indirect
|
github.com/urfave/cli v1.22.17 // indirect
|
||||||
github.com/vbatts/tar-split v0.11.6 // indirect
|
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
github.com/zclconf/go-cty v1.17.0 // indirect
|
||||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
go.etcd.io/bbolt v1.3.11 // indirect
|
go.etcd.io/bbolt v1.4.3 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
|
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
|
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.33.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
|
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.33.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.33.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/log v0.8.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||||
go.step.sm/cli-utils v0.9.0 // indirect
|
go.step.sm/cli-utils v0.9.0 // indirect
|
||||||
go.step.sm/crypto v0.45.0 // indirect
|
go.step.sm/crypto v0.45.0 // indirect
|
||||||
go.step.sm/linkedca v0.20.1 // indirect
|
go.step.sm/linkedca v0.20.1 // indirect
|
||||||
go.uber.org/automaxprocs v1.5.3 // indirect
|
go.uber.org/automaxprocs v1.5.3 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap/exp v0.2.0 // indirect
|
go.uber.org/zap/exp v0.2.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
|
||||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
golang.org/x/mod v0.23.0 // indirect
|
golang.org/x/mod v0.27.0 // indirect
|
||||||
golang.org/x/term v0.29.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
golang.org/x/time v0.8.0 // indirect
|
golang.org/x/time v0.11.0 // indirect
|
||||||
golang.org/x/tools v0.30.0 // indirect
|
golang.org/x/tools v0.36.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||||
howett.net/plist v1.0.0 // indirect
|
howett.net/plist v1.0.0 // indirect
|
||||||
|
k8s.io/api v0.32.3 // indirect
|
||||||
|
k8s.io/apimachinery v0.32.3 // indirect
|
||||||
|
k8s.io/client-go v0.32.3 // indirect
|
||||||
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
|
||||||
|
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
|
||||||
lukechampine.com/blake3 v1.3.0 // indirect
|
lukechampine.com/blake3 v1.3.0 // indirect
|
||||||
modernc.org/libc v1.61.13 // indirect
|
modernc.org/libc v1.61.13 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.8.2 // indirect
|
modernc.org/memory v1.8.2 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||||
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
|
"github.com/docker/cli/cli/command"
|
||||||
|
"github.com/docker/cli/cli/flags"
|
||||||
|
composeapi "github.com/docker/compose/v2/pkg/api"
|
||||||
|
composev2 "github.com/docker/compose/v2/pkg/compose"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildServicesOptions contains options for building services in a Compose project.
|
||||||
|
type BuildServicesOptions struct {
|
||||||
|
// BuildArgs sets build-time variables for services. Used in Dockerfiles that declare variables with ARG.
|
||||||
|
BuildArgs []string
|
||||||
|
// Check the build configuration for services without building them.
|
||||||
|
Check bool
|
||||||
|
// Deps enables to also build services declared as dependencies of the selected Services.
|
||||||
|
Deps bool
|
||||||
|
// NoCache disables the use of cache when building images.
|
||||||
|
NoCache bool
|
||||||
|
// Pull attempts to pull newer versions of the base images before building.
|
||||||
|
Pull bool
|
||||||
|
// Services specifies which services to build. If empty, all services with a build config are built.
|
||||||
|
Services []string
|
||||||
|
|
||||||
|
// Push targets are mutually exclusive.
|
||||||
|
// PushCluster uploads the built images to cluster machines after building.
|
||||||
|
PushCluster bool
|
||||||
|
// PushRegistry uploads the built images to external registries after building.
|
||||||
|
PushRegistry bool
|
||||||
|
|
||||||
|
// Cluster-specific options (only used if PushCluster is true).
|
||||||
|
// Machines is a list of machine names or IDs to push the image to. If empty, images are pushed to all machines.
|
||||||
|
Machines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildServices builds images for services in the Compose project.
|
||||||
|
func (cli *CLI) BuildServices(ctx context.Context, project *composetypes.Project, opts BuildServicesOptions) error {
|
||||||
|
// Validate push targets.
|
||||||
|
if opts.PushCluster && opts.PushRegistry {
|
||||||
|
return fmt.Errorf("cannot specify both PushCluster and PushRegistry: choose one push target")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build service images using Compose implementation.
|
||||||
|
dockerCli, err := command.NewDockerCli()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create docker client: %w", err)
|
||||||
|
}
|
||||||
|
// Initialise the Docker CLI with default options.
|
||||||
|
if err = dockerCli.Initialize(flags.NewClientOptions()); err != nil {
|
||||||
|
return fmt.Errorf("initialise docker client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeService := composev2.NewComposeService(dockerCli)
|
||||||
|
buildOpts := composeapi.BuildOptions{
|
||||||
|
Args: composetypes.NewMappingWithEquals(opts.BuildArgs),
|
||||||
|
Check: opts.Check,
|
||||||
|
Deps: opts.Deps,
|
||||||
|
NoCache: opts.NoCache,
|
||||||
|
Pull: opts.Pull,
|
||||||
|
Push: opts.PushRegistry,
|
||||||
|
Services: opts.Services,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = composeService.Build(ctx, project, buildOpts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.PushCluster {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push built service images to cluster machines.
|
||||||
|
builtServices, err := ServicesThatNeedBuild(project, opts.Services, opts.Deps)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("determine built services: %w", err)
|
||||||
|
}
|
||||||
|
if len(builtServices) == 0 {
|
||||||
|
// No services were built, nothing to push.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a line break after the build output.
|
||||||
|
fmt.Fprintln(cli.ProgressOut())
|
||||||
|
|
||||||
|
clusterClient, err := cli.ConnectCluster(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
// Push one service image at a time.
|
||||||
|
var errs []error
|
||||||
|
for _, s := range builtServices {
|
||||||
|
if s.Image == "" {
|
||||||
|
// Skip services without an image name (shouldn't happen for services with build config).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to the specified machines falling back to service x-machines.
|
||||||
|
// If none specified, push to *all* cluster machines.
|
||||||
|
var pushOpts client.PushImageOptions
|
||||||
|
|
||||||
|
if len(opts.Machines) > 0 {
|
||||||
|
pushOpts.Machines = opts.Machines
|
||||||
|
} else 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
|
||||||
|
}, cli.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServicesThatNeedBuild returns a list of services that require building.
|
||||||
|
// deps indicates whether to include services that are dependencies of the selected services.
|
||||||
|
// Implementation is based on the logic from docker/compose/v2/pkg/compose/build.go.
|
||||||
|
func ServicesThatNeedBuild(
|
||||||
|
project *composetypes.Project, selectedServices []string, deps bool,
|
||||||
|
) ([]composetypes.ServiceConfig, error) {
|
||||||
|
servicesToBuild := make([]composetypes.ServiceConfig, 0, len(project.Services))
|
||||||
|
|
||||||
|
var policy composetypes.DependencyOption = composetypes.IgnoreDependencies
|
||||||
|
if deps {
|
||||||
|
policy = composetypes.IncludeDependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also include services used as build.additional_contexts with service: prefix.
|
||||||
|
selectedServices = includeAdditionalContextsServices(project, selectedServices)
|
||||||
|
// Some build dependencies we just introduced may not be enabled, enable them.
|
||||||
|
var err error
|
||||||
|
if project, err = project.WithServicesEnabled(selectedServices...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if project, err = project.WithSelectedServices(selectedServices); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = project.ForEachService(selectedServices, func(serviceName string, service *composetypes.ServiceConfig) error {
|
||||||
|
if service.Build != nil {
|
||||||
|
servicesToBuild = append(servicesToBuild, *service)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, policy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return servicesToBuild, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// includeAdditionalContextsServices adds services referenced in build.additional_contexts to the list
|
||||||
|
// of selected services.
|
||||||
|
func includeAdditionalContextsServices(project *composetypes.Project, selectedServices []string) []string {
|
||||||
|
servicesWithDependencies := mapset.NewSet(selectedServices...)
|
||||||
|
for _, service := range selectedServices {
|
||||||
|
s, ok := project.Services[service]
|
||||||
|
if !ok {
|
||||||
|
s = project.DisabledServices[service]
|
||||||
|
}
|
||||||
|
if s.Build != nil {
|
||||||
|
for _, target := range s.Build.AdditionalContexts {
|
||||||
|
if name, found := strings.CutPrefix(target, composetypes.ServicePrefix); found {
|
||||||
|
servicesWithDependencies.Add(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if servicesWithDependencies.Cardinality() > len(selectedServices) {
|
||||||
|
return includeAdditionalContextsServices(project, servicesWithDependencies.ToSlice())
|
||||||
|
}
|
||||||
|
|
||||||
|
return servicesWithDependencies.ToSlice()
|
||||||
|
}
|
||||||
+273
-132
@@ -2,15 +2,13 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/docker/cli/cli/streams"
|
"github.com/docker/cli/cli/streams"
|
||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"github.com/psviderski/uncloud/internal/fs"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/sshexec"
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
@@ -22,17 +20,24 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultContextName = "default"
|
const (
|
||||||
|
// DefaultSSHKeyPath is the fallback location for the SSH private key when provisioning remote machines.
|
||||||
|
// Used when no key is explicitly provided and SSH agent authentication fails.
|
||||||
|
DefaultSSHKeyPath = "~/.ssh/id_ed25519"
|
||||||
|
DefaultContextName = "default"
|
||||||
|
)
|
||||||
|
|
||||||
type CLI struct {
|
type CLI struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
conn *config.MachineConnection
|
conn *config.MachineConnection
|
||||||
|
contextOverride string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new CLI instance with the given config path or remote machine connection.
|
// New creates a new CLI instance with the given config path or remote machine connection.
|
||||||
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
|
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
|
||||||
// a cluster without creating a config.
|
// a cluster without creating a config.
|
||||||
func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
|
// If a non-empty context name is given, it will override the current default.
|
||||||
|
func New(configPath string, conn *config.MachineConnection, contextName string) (*CLI, error) {
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
return &CLI{conn: conn}, nil
|
return &CLI{conn: conn}, nil
|
||||||
}
|
}
|
||||||
@@ -43,7 +48,8 @@ func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &CLI{
|
return &CLI{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
|
contextOverride: contextName,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,11 +71,40 @@ func (cli *CLI) SetCurrentContext(name string) error {
|
|||||||
return cli.Config.Save()
|
return cli.Config.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectCluster connects to a cluster using the given context name or the current context if not specified.
|
func (cli *CLI) ContextOverrideOrCurrent() string {
|
||||||
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
if cli.contextOverride != "" {
|
||||||
func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client.Client, error) {
|
return cli.contextOverride
|
||||||
|
}
|
||||||
|
if cli.Config != nil {
|
||||||
|
return cli.Config.CurrentContext
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectConnection returns the connection string if --connect or UNCLOUD_CONNECT was specified.
|
||||||
|
func (cli *CLI) DirectConnection() string {
|
||||||
if cli.conn != nil {
|
if cli.conn != nil {
|
||||||
return connectCluster(ctx, *cli.conn)
|
return cli.conn.String()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectCluster connects to a cluster using the context override or the current context if not specified.
|
||||||
|
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||||
|
func (cli *CLI) ConnectCluster(ctx context.Context) (*client.Client, error) {
|
||||||
|
return cli.ConnectClusterWithOptions(ctx, ConnectOptions{
|
||||||
|
// Default to showing progress for CLI usage.
|
||||||
|
ShowProgress: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectClusterWithOptions connects to a cluster with the given options.
|
||||||
|
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||||
|
// If the CLI has an override context, it is used instead of the current default.
|
||||||
|
// Options are useful when using the CLI as a library where you may want to disable visual feedback.
|
||||||
|
func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, opts ConnectOptions) (*client.Client, error) {
|
||||||
|
if cli.conn != nil {
|
||||||
|
return ConnectCluster(ctx, *cli.conn, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cli.Config.Contexts) == 0 {
|
if len(cli.Config.Contexts) == 0 {
|
||||||
@@ -79,6 +114,8 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client
|
|||||||
cli.Config.Path(),
|
cli.Config.Path(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contextName := cli.contextOverride
|
||||||
if contextName == "" {
|
if contextName == "" {
|
||||||
// If the cluster is not specified, use the current cluster if set.
|
// If the cluster is not specified, use the current cluster if set.
|
||||||
if cli.Config.CurrentContext == "" {
|
if cli.Config.CurrentContext == "" {
|
||||||
@@ -111,47 +148,32 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
|
// Try each connection in order until one succeeds.
|
||||||
conn := cfg.Connections[0]
|
var lastErr error
|
||||||
|
for _, conn := range cfg.Connections {
|
||||||
c, err := connectCluster(ctx, conn)
|
c, err := ConnectCluster(ctx, conn, opts)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
return c, nil
|
||||||
}
|
|
||||||
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
|
||||||
if conn.SSH != "" {
|
|
||||||
user, host, port, err := conn.SSH.Parse()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
lastErr = err
|
||||||
|
|
||||||
sshConfig := &connector.SSHConnectorConfig{
|
|
||||||
User: user,
|
|
||||||
Host: host,
|
|
||||||
Port: port,
|
|
||||||
KeyPath: keyPath,
|
|
||||||
}
|
|
||||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
|
||||||
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
|
||||||
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errors.New("connection configuration is invalid")
|
return nil, fmt.Errorf("failed to connect to cluster context '%s': "+
|
||||||
|
"all connections (%d) in the Uncloud config (%s) failed; last error: %w",
|
||||||
|
contextName, len(cfg.Connections), cli.Config.Path(), lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
type InitClusterOptions struct {
|
type InitClusterOptions struct {
|
||||||
Context string
|
Context string
|
||||||
MachineName string
|
MachineName string
|
||||||
Network netip.Prefix
|
Network netip.Prefix
|
||||||
PublicIP *netip.Addr
|
PublicIP *netip.Addr
|
||||||
RemoteMachine *RemoteMachine
|
RemoteMachine *RemoteMachine
|
||||||
Version string
|
SkipInstall bool
|
||||||
|
Version string
|
||||||
|
AutoConfirm bool
|
||||||
|
WireguardEndpoints []*pb.IPPort
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
|
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
|
||||||
@@ -161,19 +183,16 @@ func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*clie
|
|||||||
return cli.initRemoteMachine(ctx, opts)
|
return cli.initRemoteMachine(ctx, opts)
|
||||||
}
|
}
|
||||||
// TODO: implement local machine initialisation
|
// TODO: implement local machine initialisation
|
||||||
return nil, fmt.Errorf("local machine initialisation is not implemented yet")
|
return nil, fmt.Errorf("local machine initialisation is not implemented yet. Please specify a remote machine")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||||
contextName := opts.Context
|
contextName, err := cli.newContextName(opts.Context)
|
||||||
if contextName == "" {
|
if err != nil {
|
||||||
contextName = defaultContextName
|
return nil, err
|
||||||
}
|
|
||||||
if _, ok := cli.Config.Contexts[contextName]; ok {
|
|
||||||
return nil, fmt.Errorf("cluster context '%s' already exists", contextName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
machineClient, err := cli.provisionRemoteMachine(ctx, *opts.RemoteMachine, opts.Version)
|
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -190,7 +209,12 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
|||||||
return nil, fmt.Errorf("inspect machine: %w", err)
|
return nil, fmt.Errorf("inspect machine: %w", err)
|
||||||
}
|
}
|
||||||
if minfo.Id != "" {
|
if minfo.Id != "" {
|
||||||
if err = cli.promptResetMachine(); err != nil {
|
if !opts.AutoConfirm {
|
||||||
|
if err = promptResetMachine(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = resetAndWaitMachine(ctx, machineClient.MachineClient); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,8 +231,9 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := &pb.InitClusterRequest{
|
req := &pb.InitClusterRequest{
|
||||||
MachineName: opts.MachineName,
|
MachineName: opts.MachineName,
|
||||||
Network: pb.NewIPPrefix(opts.Network),
|
Network: pb.NewIPPrefix(opts.Network),
|
||||||
|
WireguardEndpoints: opts.WireguardEndpoints,
|
||||||
}
|
}
|
||||||
if opts.PublicIP != nil {
|
if opts.PublicIP != nil {
|
||||||
if opts.PublicIP.IsValid() {
|
if opts.PublicIP.IsValid() {
|
||||||
@@ -235,8 +260,21 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
|||||||
|
|
||||||
// Save the machine's SSH connection details in the context config.
|
// Save the machine's SSH connection details in the context config.
|
||||||
connCfg := config.MachineConnection{
|
connCfg := config.MachineConnection{
|
||||||
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
|
||||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||||
|
MachineID: resp.Machine.Id,
|
||||||
|
}
|
||||||
|
if opts.RemoteMachine.UseSSHCLI {
|
||||||
|
connCfg.SSHCLI = config.NewSSHDestination(
|
||||||
|
opts.RemoteMachine.User,
|
||||||
|
opts.RemoteMachine.Host,
|
||||||
|
opts.RemoteMachine.Port,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
connCfg.SSH = config.NewSSHDestination(
|
||||||
|
opts.RemoteMachine.User,
|
||||||
|
opts.RemoteMachine.Host,
|
||||||
|
opts.RemoteMachine.Port,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||||
if err = cli.Config.Save(); err != nil {
|
if err = cli.Config.Save(); err != nil {
|
||||||
@@ -245,30 +283,61 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
|||||||
return machineClient, nil
|
return machineClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddMachineOptions struct {
|
// newContextName returns a unique name for a new cluster context. If the provided name is not DefaultContextName,
|
||||||
Context string
|
// and it's already taken, an error is returned. If the name is not provided or is DefaultContextName, the first
|
||||||
MachineName string
|
// available name "default[-N]" is returned.
|
||||||
PublicIP *netip.Addr
|
func (cli *CLI) newContextName(name string) (string, error) {
|
||||||
RemoteMachine RemoteMachine
|
if name == "" {
|
||||||
Version string
|
name = DefaultContextName
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := cli.Config.Contexts[name]; !exists {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If non-default context already exists, error out.
|
||||||
|
if name != DefaultContextName {
|
||||||
|
return "", fmt.Errorf("cluster context '%s' already exists", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default context already exists, generate a numbered suffix to make it unique.
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
name = fmt.Sprintf("%s-%d", DefaultContextName, i)
|
||||||
|
if _, exists := cli.Config.Contexts[name]; !exists {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddMachine provisions a remote machine and adds it to the cluster. It returns a client to interact with the machine
|
type AddMachineOptions struct {
|
||||||
// which should be closed after use by the caller.
|
MachineName string
|
||||||
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, error) {
|
PublicIP *netip.Addr
|
||||||
contextName := opts.Context
|
RemoteMachine *RemoteMachine
|
||||||
if contextName == "" {
|
SkipInstall bool
|
||||||
contextName = cli.Config.CurrentContext
|
Version string
|
||||||
}
|
AutoConfirm bool
|
||||||
c, err := cli.ConnectCluster(ctx, contextName)
|
WireguardEndpoints []*pb.IPPort
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
|
||||||
}
|
|
||||||
defer c.Close()
|
|
||||||
|
|
||||||
machineClient, err := cli.provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
// AddMachine provisions a remote machine and adds it to the cluster. It returns a cluster client and a machine client.
|
||||||
|
// The cluster client is connected to the existing machine in the cluster. It was used to add the new machine to the
|
||||||
|
// cluster. The machine client is connected to the new machine and can be used to interact with it.
|
||||||
|
// Both client should be closed after use by the caller.
|
||||||
|
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) {
|
||||||
|
contextName := cli.ContextOverrideOrCurrent()
|
||||||
|
c, err := cli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -277,13 +346,30 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
||||||
|
// TODO: refactor to use client.InspectMachine.
|
||||||
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("inspect machine: %w", err)
|
return nil, nil, fmt.Errorf("inspect machine: %w", err)
|
||||||
}
|
}
|
||||||
if minfo.Id != "" {
|
if minfo.Id != "" {
|
||||||
if err = cli.promptResetMachine(); err != nil {
|
// Check if the machine is already a member of this cluster.
|
||||||
return nil, err
|
machines, err := c.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("list cluster machines: %w", err)
|
||||||
|
}
|
||||||
|
if slices.ContainsFunc(machines, func(m *pb.MachineMember) bool {
|
||||||
|
return m.Machine.Id == minfo.Id
|
||||||
|
}) {
|
||||||
|
return nil, nil, fmt.Errorf("machine is already a member of this cluster (%s)", minfo.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.AutoConfirm {
|
||||||
|
if err = promptResetMachine(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = resetAndWaitMachine(ctx, machineClient.MachineClient); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,25 +378,31 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
// TODO(lhf): remove Unimplemented check when v0.9.0 is released.
|
// TODO(lhf): remove Unimplemented check when v0.9.0 is released.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if status.Convert(err).Code() != codes.Unimplemented {
|
if status.Convert(err).Code() != codes.Unimplemented {
|
||||||
return nil, fmt.Errorf("check machine prerequisites: %w", err)
|
return nil, nil, fmt.Errorf("check machine prerequisites: %w", err)
|
||||||
}
|
}
|
||||||
} else if !checkResp.Satisfied {
|
} else if !checkResp.Satisfied {
|
||||||
return nil, fmt.Errorf("machine prerequisites not satisfied: %s", checkResp.Error)
|
return nil, nil, fmt.Errorf("machine prerequisites not satisfied: %s", checkResp.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenResp, err := machineClient.Token(ctx, &emptypb.Empty{})
|
tokenResp, err := machineClient.Token(ctx, &emptypb.Empty{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get remote machine token: %w", err)
|
return nil, nil, fmt.Errorf("get remote machine token: %w", err)
|
||||||
}
|
}
|
||||||
token, err := machine.ParseToken(tokenResp.Token)
|
token, err := machine.ParseToken(tokenResp.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse remote machine token: %w", err)
|
return nil, nil, fmt.Errorf("parse remote machine token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the machine in the cluster using its public key and endpoints from the token.
|
// Register the machine in the cluster using its public key and WireGuard endpoints from the token,
|
||||||
endpoints := make([]*pb.IPPort, len(token.Endpoints))
|
// or use the explicitly provided WireGuard endpoints if any.
|
||||||
for i, addrPort := range token.Endpoints {
|
var endpoints []*pb.IPPort
|
||||||
endpoints[i] = pb.NewIPPort(addrPort)
|
if len(opts.WireguardEndpoints) > 0 {
|
||||||
|
endpoints = opts.WireguardEndpoints
|
||||||
|
} else {
|
||||||
|
endpoints = make([]*pb.IPPort, len(token.Endpoints))
|
||||||
|
for i, addrPort := range token.Endpoints {
|
||||||
|
endpoints[i] = pb.NewIPPort(addrPort)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
addReq := &pb.AddMachineRequest{
|
addReq := &pb.AddMachineRequest{
|
||||||
Name: opts.MachineName,
|
Name: opts.MachineName,
|
||||||
@@ -330,13 +422,25 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
|
|
||||||
addResp, err := c.AddMachine(ctx, addReq)
|
addResp, err := c.AddMachine(ctx, addReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err)
|
return nil, nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List other machines in the cluster to include them in the join request.
|
// Get the current store DB version from the cluster to pass to the join request.
|
||||||
|
var storeDBVersion int64
|
||||||
|
inspectResp, err := c.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
// TODO(lhf): remove Unimplemented check when v0.17.0 is released.
|
||||||
|
if status.Convert(err).Code() != codes.Unimplemented {
|
||||||
|
return nil, nil, fmt.Errorf("inspect current cluster machine: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
storeDBVersion = inspectResp.Machines[0].StoreDbVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the most up-to-date list of other machines in the cluster to include them in the join request.
|
||||||
machines, err := c.ListMachines(ctx, nil)
|
machines, err := c.ListMachines(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list cluster machines: %w", err)
|
return nil, nil, fmt.Errorf("list cluster machines: %w", err)
|
||||||
}
|
}
|
||||||
otherMachines := make([]*pb.MachineInfo, 0, len(machines)-1)
|
otherMachines := make([]*pb.MachineInfo, 0, len(machines)-1)
|
||||||
for _, m := range machines {
|
for _, m := range machines {
|
||||||
@@ -347,11 +451,12 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
|
|
||||||
// Configure the remote machine to join the cluster.
|
// Configure the remote machine to join the cluster.
|
||||||
joinReq := &pb.JoinClusterRequest{
|
joinReq := &pb.JoinClusterRequest{
|
||||||
Machine: addResp.Machine,
|
Machine: addResp.Machine,
|
||||||
OtherMachines: otherMachines,
|
OtherMachines: otherMachines,
|
||||||
|
MinStoreDbVersion: storeDBVersion,
|
||||||
}
|
}
|
||||||
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
|
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
|
||||||
return nil, fmt.Errorf("join cluster: %w", err)
|
return nil, nil, fmt.Errorf("join cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: fix empty context name when using the current context (contextName == "").
|
// TODO: fix empty context name when using the current context (contextName == "").
|
||||||
@@ -359,42 +464,94 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
|
|
||||||
// Save the machine's SSH connection details in the context config.
|
// Save the machine's SSH connection details in the context config.
|
||||||
connCfg := config.MachineConnection{
|
connCfg := config.MachineConnection{
|
||||||
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
|
||||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||||
|
MachineID: addResp.Machine.Id,
|
||||||
}
|
}
|
||||||
if contextName == "" {
|
if opts.RemoteMachine.UseSSHCLI {
|
||||||
contextName = cli.Config.CurrentContext
|
connCfg.SSHCLI = config.NewSSHDestination(
|
||||||
|
opts.RemoteMachine.User,
|
||||||
|
opts.RemoteMachine.Host,
|
||||||
|
opts.RemoteMachine.Port,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
connCfg.SSH = config.NewSSHDestination(
|
||||||
|
opts.RemoteMachine.User,
|
||||||
|
opts.RemoteMachine.Host,
|
||||||
|
opts.RemoteMachine.Port,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||||
if err = cli.Config.Save(); err != nil {
|
if err = cli.Config.Save(); err != nil {
|
||||||
return nil, fmt.Errorf("save config: %w", err)
|
return nil, nil, fmt.Errorf("save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return machineClient, nil
|
return c, machineClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and returns
|
// provisionOrConnectRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and
|
||||||
// a machine API client to interact with the machine. The client should be closed after use by the caller.
|
// returns a machine API client to interact with the machine. The client should be closed after use by the caller.
|
||||||
// The version parameter specifies the version of the Uncloud daemon to install. If empty, the latest version is used.
|
// The version parameter specifies the version of the Uncloud daemon to install. If empty, the latest version is used.
|
||||||
func (cli *CLI) provisionRemoteMachine(
|
// If skipInstall is true, the installation step is skipped, and it is assumed that the Uncloud daemon and dependencies
|
||||||
ctx context.Context, remoteMachine RemoteMachine, version string,
|
// are already installed and running.
|
||||||
|
// The remoteMachine.SSHKeyPath could be updated to the default SSH key path if it is not set and the SSH agent
|
||||||
|
// authentication fails.
|
||||||
|
func provisionOrConnectRemoteMachine(
|
||||||
|
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
|
||||||
) (*client.Client, error) {
|
) (*client.Client, error) {
|
||||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
// Use SSH CLI
|
||||||
|
if remoteMachine.UseSSHCLI {
|
||||||
|
exec := sshexec.NewSSHCLIRemote(
|
||||||
|
remoteMachine.User,
|
||||||
|
remoteMachine.Host,
|
||||||
|
remoteMachine.Port,
|
||||||
|
remoteMachine.KeyPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !skipInstall {
|
||||||
|
if err := provisionMachine(ctx, exec, version); err != nil {
|
||||||
|
return nil, fmt.Errorf("provision machine: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConfig := &connector.SSHConnectorConfig{
|
||||||
|
User: remoteMachine.User,
|
||||||
|
Host: remoteMachine.Host,
|
||||||
|
Port: remoteMachine.Port,
|
||||||
|
KeyPath: remoteMachine.KeyPath,
|
||||||
|
}
|
||||||
|
machineClient, err := client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to remote machine: %w", err)
|
||||||
|
}
|
||||||
|
return machineClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Go SSH
|
||||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
||||||
|
// If the SSH connection using SSH agent fails and no key path is provided, try to use the default SSH key.
|
||||||
|
if err != nil && remoteMachine.KeyPath == "" {
|
||||||
|
remoteMachine.KeyPath = DefaultSSHKeyPath
|
||||||
|
sshClient, err = sshexec.Connect(
|
||||||
|
remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"SSH login to remote machine %s: %w",
|
"SSH login to remote machine %s: %w",
|
||||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
exec := sshexec.NewRemote(sshClient)
|
|
||||||
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
if !skipInstall {
|
||||||
if err = provisionMachine(ctx, exec, version); err != nil {
|
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||||
return nil, fmt.Errorf("provision machine: %w", err)
|
exec := sshexec.NewRemote(sshClient)
|
||||||
|
if err = provisionMachine(ctx, exec, version); err != nil {
|
||||||
|
return nil, fmt.Errorf("provision machine: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var machineClient *client.Client
|
var machineClient *client.Client
|
||||||
if remoteMachine.User == "root" {
|
if remoteMachine.User == "root" || skipInstall {
|
||||||
// Create a machine API client over the established SSH connection to the remote machine.
|
// Create a machine API client over the established SSH connection to the remote machine.
|
||||||
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
||||||
} else {
|
} else {
|
||||||
@@ -414,29 +571,13 @@ func (cli *CLI) provisionRemoteMachine(
|
|||||||
return machineClient, nil
|
return machineClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) promptResetMachine() error {
|
// SetClusterContextIfUnset sets the cluster context override only if no --context flag was used
|
||||||
var confirm bool
|
// and no --connect direct connection is active.
|
||||||
form := huh.NewForm(
|
func (cli *CLI) SetClusterContextIfUnset(name string) {
|
||||||
huh.NewGroup(
|
if name == "" || cli.contextOverride != "" || cli.conn != nil {
|
||||||
huh.NewConfirm().
|
return
|
||||||
Title(
|
|
||||||
"The remote machine is already initialised as a cluster member. Do you want to reset it first?",
|
|
||||||
).
|
|
||||||
Affirmative("Yes!").
|
|
||||||
Negative("No").
|
|
||||||
Value(&confirm),
|
|
||||||
),
|
|
||||||
).WithAccessible(true)
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return fmt.Errorf("prompt user to confirm: %w", err)
|
|
||||||
}
|
}
|
||||||
|
cli.contextOverride = name
|
||||||
if !confirm {
|
|
||||||
return fmt.Errorf("remote machine is already initialised as a cluster member")
|
|
||||||
}
|
|
||||||
// TODO: implement resetting the remote machine.
|
|
||||||
return fmt.Errorf("resetting the remote machine is not implemented yet. " +
|
|
||||||
"Please manually run 'uncloud-uninstall' on the remote machine to fully uninstall Uncloud from it")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProgressOut returns an output stream for progress writer.
|
// ProgressOut returns an output stream for progress writer.
|
||||||
|
|||||||
@@ -53,11 +53,15 @@ func (c *Config) Read() error {
|
|||||||
|
|
||||||
func (c *Config) Save() error {
|
func (c *Config) Save() error {
|
||||||
dir, _ := filepath.Split(c.path)
|
dir, _ := filepath.Split(c.path)
|
||||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
// If dir is empty (e.g., when path is just a filename), use current directory
|
||||||
|
if dir == "" {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
return fmt.Errorf("create config directory '%s': %w", dir, err)
|
return fmt.Errorf("create config directory '%s': %w", dir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(c.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
f, err := os.OpenFile(c.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write config file '%s': %w", c.path, err)
|
return fmt.Errorf("write config file '%s': %w", c.path, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfig_Save(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create a temporary directory for the test
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Change to temp directory so relative paths resolve correctly
|
||||||
|
originalDir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get current directory: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.Chdir(originalDir); err != nil {
|
||||||
|
t.Logf("Failed to restore original directory: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configPath string
|
||||||
|
contextName string
|
||||||
|
expectFileAt string // Expected file location for verification
|
||||||
|
useAbsolutePath bool // Whether to use absolute path for expectFileAt
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "relative path without prefix",
|
||||||
|
configPath: "test-config.yaml",
|
||||||
|
contextName: "test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative path with prefix",
|
||||||
|
configPath: "./test-config-2.yaml",
|
||||||
|
contextName: "test2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute path",
|
||||||
|
configPath: filepath.Join(tmpDir, "absolute-config.yaml"),
|
||||||
|
contextName: "test3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatalf("Failed to change to temp directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
CurrentContext: tt.contextName,
|
||||||
|
Contexts: map[string]*Context{
|
||||||
|
tt.contextName: {
|
||||||
|
Name: tt.contextName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
path: tt.configPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should not fail when saving the config
|
||||||
|
err := cfg.Save()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error when saving config, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file was created
|
||||||
|
if _, err := os.Stat(tt.configPath); os.IsNotExist(err) {
|
||||||
|
t.Errorf("Config file was not created at expected path: %s", tt.configPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -9,52 +11,90 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
DefaultSSHUser = "root"
|
|
||||||
DefaultSSHPort = 22
|
|
||||||
)
|
|
||||||
|
|
||||||
type MachineConnection struct {
|
type MachineConnection struct {
|
||||||
SSH SSHDestination `yaml:"ssh,omitempty"`
|
SSH SSHDestination `yaml:"ssh,omitempty"`
|
||||||
|
SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"`
|
||||||
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
|
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
|
||||||
// TCP is the address and port of the machine's API server.
|
// TCP is the address and port of the machine's API server.
|
||||||
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
|
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
|
||||||
TCP *netip.AddrPort `yaml:"tcp,omitempty"`
|
TCP *netip.AddrPort `yaml:"tcp,omitempty"`
|
||||||
Host string `yaml:"host,omitempty"`
|
// Unix is the path to the machine's API unix socket.
|
||||||
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
Unix string `yaml:"unix,omitempty"`
|
||||||
|
Host string `yaml:"host,omitempty"`
|
||||||
|
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
||||||
|
MachineID string `yaml:"machine_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MachineConnection) String() string {
|
||||||
|
if c.SSH != "" {
|
||||||
|
return "ssh://" + string(c.SSH)
|
||||||
|
} else if c.SSHCLI != "" {
|
||||||
|
return "ssh+cli://" + string(c.SSHCLI)
|
||||||
|
} else if c.TCP != nil && c.TCP.IsValid() {
|
||||||
|
return fmt.Sprintf("tcp://%s", c.TCP)
|
||||||
|
} else if c.Unix != "" {
|
||||||
|
return fmt.Sprintf("unix://%s", c.Unix)
|
||||||
|
}
|
||||||
|
return "unknown connection"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MachineConnection) Validate() error {
|
||||||
|
setCount := 0
|
||||||
|
if c.SSH != "" {
|
||||||
|
setCount++
|
||||||
|
}
|
||||||
|
if c.SSHCLI != "" {
|
||||||
|
setCount++
|
||||||
|
}
|
||||||
|
if c.TCP != nil && c.TCP.IsValid() {
|
||||||
|
setCount++
|
||||||
|
}
|
||||||
|
if c.Unix != "" {
|
||||||
|
setCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if setCount == 0 {
|
||||||
|
return errors.New("no connection method specified (ssh, ssh_cli, tcp, or unix required)")
|
||||||
|
}
|
||||||
|
if setCount > 1 {
|
||||||
|
return errors.New("only one connection method allowed per connection (ssh, ssh_cli, tcp, or unix)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||||
// The default user "root" and port 22 can be omitted.
|
// Empty user or port components are omitted.
|
||||||
type SSHDestination string
|
type SSHDestination string
|
||||||
|
|
||||||
|
// NewSSHDestination constructs an SSHDestination from user, host, and port components.
|
||||||
|
// If user is empty, it is omitted.
|
||||||
|
// If port is 0, it is omitted.
|
||||||
func NewSSHDestination(user, host string, port int) SSHDestination {
|
func NewSSHDestination(user, host string, port int) SSHDestination {
|
||||||
dst := host
|
dst := host
|
||||||
if port != 0 && port != DefaultSSHPort {
|
if port != 0 {
|
||||||
dst = net.JoinHostPort(host, strconv.Itoa(port))
|
dst = net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
}
|
}
|
||||||
if user == "" {
|
if user != "" {
|
||||||
user = DefaultSSHUser
|
dst = fmt.Sprintf("%s@%s", user, dst)
|
||||||
}
|
}
|
||||||
dst = user + "@" + dst
|
|
||||||
return SSHDestination(dst)
|
return SSHDestination(dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse parses the SSH destination string into user, host, and port components.
|
||||||
|
// If user is not specified, it returns an empty string.
|
||||||
|
// If port is not specified, it returns 0.
|
||||||
func (d SSHDestination) Parse() (user string, host string, port int, err error) {
|
func (d SSHDestination) Parse() (user string, host string, port int, err error) {
|
||||||
host = string(d)
|
host = string(d)
|
||||||
if strings.Contains(host, "@") {
|
if strings.Contains(host, "@") {
|
||||||
user, host, _ = strings.Cut(host, "@")
|
user, host, _ = strings.Cut(host, "@")
|
||||||
}
|
}
|
||||||
if user == "" {
|
|
||||||
user = DefaultSSHUser
|
|
||||||
}
|
|
||||||
h, p, sErr := net.SplitHostPort(host)
|
h, p, sErr := net.SplitHostPort(host)
|
||||||
if sErr == nil {
|
if sErr == nil {
|
||||||
host = h
|
host = h
|
||||||
port, err = strconv.Atoi(p)
|
port, err = strconv.Atoi(p)
|
||||||
}
|
}
|
||||||
if port == 0 {
|
|
||||||
port = DefaultSSHPort
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMachineConnection_String(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
conn MachineConnection
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ssh connection",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host.com",
|
||||||
|
},
|
||||||
|
want: "ssh://user@host.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh connection with port",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host.com:2222",
|
||||||
|
},
|
||||||
|
want: "ssh://user@host.com:2222",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh_cli connection",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSHCLI: "user@host.com",
|
||||||
|
},
|
||||||
|
want: "ssh+cli://user@host.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh_cli connection with port",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSHCLI: "user@host.com:2222",
|
||||||
|
},
|
||||||
|
want: "ssh+cli://user@host.com:2222",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tcp connection",
|
||||||
|
conn: MachineConnection{
|
||||||
|
TCP: func() *netip.AddrPort {
|
||||||
|
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||||
|
return &addr
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
want: "tcp://10.0.0.1:8080",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unix connection",
|
||||||
|
conn: MachineConnection{
|
||||||
|
Unix: "/run/uncloud/uncloud.sock",
|
||||||
|
},
|
||||||
|
want: "unix:///run/uncloud/uncloud.sock",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no connection",
|
||||||
|
conn: MachineConnection{},
|
||||||
|
want: "unknown connection",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh connection with machine id",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host.com",
|
||||||
|
MachineID: "ed98b9f7575308c340263cd279e3b568",
|
||||||
|
},
|
||||||
|
// String() does not use MachineID; just verifying entry with it is valid
|
||||||
|
want: "ssh://user@host.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := tt.conn.String()
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMachineConnection_Validate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
conn MachineConnection
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ssh only - valid",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh_cli only - valid",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSHCLI: "user@host",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tcp only - valid",
|
||||||
|
conn: MachineConnection{
|
||||||
|
TCP: func() *netip.AddrPort {
|
||||||
|
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||||
|
return &addr
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unix only - valid",
|
||||||
|
conn: MachineConnection{
|
||||||
|
Unix: "/path/to/socket",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no connection method - error",
|
||||||
|
conn: MachineConnection{},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "no connection method specified",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh and ssh_cli - error",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host",
|
||||||
|
SSHCLI: "user@host",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "only one connection method allowed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh and unix - error",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host",
|
||||||
|
Unix: "/path/to/socket",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "only one connection method allowed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh and tcp - error",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host",
|
||||||
|
TCP: func() *netip.AddrPort {
|
||||||
|
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||||
|
return &addr
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "only one connection method allowed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh_cli and tcp - error",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSHCLI: "user@host",
|
||||||
|
TCP: func() *netip.AddrPort {
|
||||||
|
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||||
|
return &addr
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "only one connection method allowed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all three - error",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host",
|
||||||
|
SSHCLI: "user@host2",
|
||||||
|
TCP: func() *netip.AddrPort {
|
||||||
|
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||||
|
return &addr
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "only one connection method allowed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := tt.conn.Validate()
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.errMsg)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,3 +4,21 @@ type Context struct {
|
|||||||
Name string `yaml:"-"`
|
Name string `yaml:"-"`
|
||||||
Connections []MachineConnection `yaml:"connections"`
|
Connections []MachineConnection `yaml:"connections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Context) SetDefaultConnection(index int) {
|
||||||
|
// Do nothing if the index is out of range.
|
||||||
|
if index < 0 || index >= len(c.Connections) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := c.Connections[index]
|
||||||
|
newConnections := make([]MachineConnection, 0, len(c.Connections))
|
||||||
|
newConnections = append(newConnections, selected)
|
||||||
|
|
||||||
|
for i, conn := range c.Connections {
|
||||||
|
if i != index {
|
||||||
|
newConnections = append(newConnections, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Connections = newConnections
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/spinner"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectOptions configures the behavior of cluster connection attempts.
|
||||||
|
type ConnectOptions struct {
|
||||||
|
// Whether to show connection progress spinner if stdout is a terminal or progress logs if not.
|
||||||
|
ShowProgress bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts ConnectOptions) (*client.Client, error) {
|
||||||
|
if opts.ShowProgress {
|
||||||
|
return connectClusterWithProgress(ctx, conn)
|
||||||
|
}
|
||||||
|
return connectCluster(ctx, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectClusterWithProgress connects to the cluster while displaying a progress spinner.
|
||||||
|
// If the stdout is not a terminal, it falls back to simple progress logs to stderr.
|
||||||
|
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||||
|
// If stdout is not a terminal, fall back to simple progress logs.
|
||||||
|
if !tui.IsStdoutTerminal() {
|
||||||
|
fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
|
||||||
|
cli, err := connectCluster(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "Connection failed:", err)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(os.Stderr, "Connected to cluster.")
|
||||||
|
}
|
||||||
|
return cli, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the connection TUI model.
|
||||||
|
p := tea.NewProgram(newConnectModel(ctx, conn))
|
||||||
|
model, err := p.Run()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("run connection TUI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := model.(connectModel)
|
||||||
|
return m.result.client, m.result.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||||
|
// Determine which SSH type is configured
|
||||||
|
var sshDest config.SSHDestination
|
||||||
|
var useSSHCLI bool
|
||||||
|
|
||||||
|
// Validate connection configuration early to provide clear error messages.
|
||||||
|
if err := conn.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid connection configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if conn.SSH != "" {
|
||||||
|
sshDest = conn.SSH
|
||||||
|
useSSHCLI = false
|
||||||
|
} else if conn.SSHCLI != "" {
|
||||||
|
sshDest = conn.SSHCLI
|
||||||
|
useSSHCLI = true
|
||||||
|
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||||
|
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||||
|
} else if conn.Unix != "" {
|
||||||
|
return client.New(ctx, connector.NewUnixConnector(conn.Unix))
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("connection configuration is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse SSH destination and create config (shared for both types)
|
||||||
|
user, host, port, err := sshDest.Parse()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse SSH connection %q: %w", sshDest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
||||||
|
|
||||||
|
sshConfig := &connector.SSHConnectorConfig{
|
||||||
|
User: user,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
KeyPath: keyPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create appropriate connector based on type
|
||||||
|
if useSSHCLI {
|
||||||
|
return client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
|
||||||
|
}
|
||||||
|
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
|
||||||
|
type connectModel struct {
|
||||||
|
ctx context.Context
|
||||||
|
conn config.MachineConnection
|
||||||
|
spinner spinner.Model
|
||||||
|
// showSpinner controls whether the spinner is visible (delayed to avoid flashing).
|
||||||
|
showSpinner bool
|
||||||
|
// done indicates whether the connection attempt has completed (successfully or with error).
|
||||||
|
done bool
|
||||||
|
// result holds the result of the connection attempt.
|
||||||
|
result connectResultMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
type connectResultMsg struct {
|
||||||
|
client *client.Client
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// showSpinnerMsg is sent after a delay to show the spinner.
|
||||||
|
type showSpinnerMsg struct{}
|
||||||
|
|
||||||
|
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
|
||||||
|
s := spinner.New()
|
||||||
|
s.Spinner = spinner.MiniDot
|
||||||
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Yellow) // the same yellow as in compose progress
|
||||||
|
|
||||||
|
return connectModel{
|
||||||
|
ctx: ctx,
|
||||||
|
conn: conn,
|
||||||
|
spinner: s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m connectModel) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
m.spinner.Tick,
|
||||||
|
m.connect(),
|
||||||
|
m.delayShowSpinner(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m connectModel) connect() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
cli, err := connectCluster(m.ctx, m.conn)
|
||||||
|
return connectResultMsg{
|
||||||
|
client: cli,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delayShowSpinner returns a command that sends a message to show the spinner after a delay.
|
||||||
|
// This avoids flashing the spinner if the connection is fast.
|
||||||
|
func (m connectModel) delayShowSpinner() tea.Cmd {
|
||||||
|
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
|
||||||
|
return showSpinnerMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
select {
|
||||||
|
case <-m.ctx.Done():
|
||||||
|
m.result.err = m.ctx.Err()
|
||||||
|
m.done = true
|
||||||
|
return m, tea.Quit
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case connectResultMsg:
|
||||||
|
m.result = msg
|
||||||
|
m.done = true
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case showSpinnerMsg:
|
||||||
|
// Only show spinner if connection hasn't completed yet.
|
||||||
|
if !m.done {
|
||||||
|
m.showSpinner = true
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case spinner.TickMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
if msg.String() == "ctrl+c" {
|
||||||
|
m.result.err = fmt.Errorf("connection cancelled")
|
||||||
|
m.done = true
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m connectModel) View() tea.View {
|
||||||
|
// Don't show anything if done or spinner not yet visible.
|
||||||
|
if m.done || !m.showSpinner {
|
||||||
|
return tea.NewView("")
|
||||||
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
|
||||||
|
return tea.NewView(fmt.Sprintf("%s %s\n",
|
||||||
|
m.spinner.View(),
|
||||||
|
fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())),
|
||||||
|
))
|
||||||
|
}
|
||||||
+37
-1
@@ -1,7 +1,15 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
|
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
|
||||||
@@ -12,7 +20,7 @@ func ExpandCommaSeparatedValues(values []string) []string {
|
|||||||
|
|
||||||
var expanded []string
|
var expanded []string
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
for _, v := range strings.Split(value, ",") {
|
for v := range strings.SplitSeq(value, ",") {
|
||||||
if v = strings.TrimSpace(v); v != "" {
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
expanded = append(expanded, v)
|
expanded = append(expanded, v)
|
||||||
}
|
}
|
||||||
@@ -21,3 +29,31 @@ func ExpandCommaSeparatedValues(values []string) []string {
|
|||||||
|
|
||||||
return expanded
|
return expanded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BindEnvToFlag assigns the value of an environment variable to the given command flag if the flag has not been set.
|
||||||
|
func BindEnvToFlag(cmd *cobra.Command, flagName, envVar string) {
|
||||||
|
if value := os.Getenv(envVar); value != "" && !cmd.Flags().Changed(flagName) {
|
||||||
|
if err := cmd.Flags().Set(flagName, value); err != nil {
|
||||||
|
log.Fatalf("Failed to bind environment variable '%s' to flag '%s': %v", envVar, flagName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseWireGuardEndpoints parses a list of endpoint strings into a list of IPPort protobuf messages. Each value can
|
||||||
|
// be an IP address, IP:PORT, IPv6, or [IPv6]:PORT. If the port is omitted, the default WireGuard port is used.
|
||||||
|
func ParseWireGuardEndpoints(values []string) ([]*pb.IPPort, error) {
|
||||||
|
endpoints := make([]*pb.IPPort, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
ap, err := netip.ParseAddrPort(v)
|
||||||
|
if err != nil {
|
||||||
|
// Try parsing as a bare IP address and use the default WireGuard port.
|
||||||
|
addr, addrErr := netip.ParseAddr(v)
|
||||||
|
if addrErr != nil {
|
||||||
|
return nil, fmt.Errorf("invalid endpoint '%s': must be IP, IPv6, IP:PORT, or [IPv6]:PORT", v)
|
||||||
|
}
|
||||||
|
ap = netip.AddrPortFrom(addr, network.WireGuardPort)
|
||||||
|
}
|
||||||
|
endpoints = append(endpoints, pb.NewIPPort(ap))
|
||||||
|
}
|
||||||
|
return endpoints, nil
|
||||||
|
}
|
||||||
|
|||||||
+102
-7
@@ -2,21 +2,32 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/huh/v2"
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/sshexec"
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: support pinning the script version to the CLI version.
|
const (
|
||||||
const installScriptURL = "https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh"
|
// TODO: support pinning the script version to the CLI version.
|
||||||
|
installScriptURL = "https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh"
|
||||||
|
rootUser = "root"
|
||||||
|
)
|
||||||
|
|
||||||
type RemoteMachine struct {
|
type RemoteMachine struct {
|
||||||
User string
|
User string
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
KeyPath string
|
KeyPath string
|
||||||
|
UseSSHCLI bool // indicates ssh+cli:// should be used
|
||||||
}
|
}
|
||||||
|
|
||||||
func installCmd(user string, version string) string {
|
func installCmd(user string, version string) string {
|
||||||
@@ -24,7 +35,7 @@ func installCmd(user string, version string) string {
|
|||||||
var env []string
|
var env []string
|
||||||
|
|
||||||
// Add the SSH user (non-root) to the uncloud group to allow access to the Uncloud daemon unix socket.
|
// Add the SSH user (non-root) to the uncloud group to allow access to the Uncloud daemon unix socket.
|
||||||
if user != "root" {
|
if user != rootUser {
|
||||||
sudoPrefix = "sudo"
|
sudoPrefix = "sudo"
|
||||||
env = append(env, "UNCLOUD_GROUP_ADD_USER="+sshexec.Quote(user))
|
env = append(env, "UNCLOUD_GROUP_ADD_USER="+sshexec.Quote(user))
|
||||||
}
|
}
|
||||||
@@ -46,6 +57,26 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string
|
|||||||
return fmt.Errorf("run whoami: %w", err)
|
return fmt.Errorf("run whoami: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user != rootUser {
|
||||||
|
// 'sudo -n' is not used because it fails with 'sudo: a password is required' when the user has no password
|
||||||
|
// in /etc/shadow even though it may have valid sudo access.
|
||||||
|
out, err := exec.Run(ctx, "sudo true")
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(out, "password is required") {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"user '%[1]s' requires a password for sudo, but Uncloud needs passwordless sudo or root access "+
|
||||||
|
"to install and configure the uncloudd daemon on the remote machine.\n\n"+
|
||||||
|
"Possible solutions:\n"+
|
||||||
|
"1. Use root user or a user with passwordless sudo instead.\n"+
|
||||||
|
"2. Configure passwordless sudo for the user '%[1]s' by running on the remote machine:\n"+
|
||||||
|
" echo '%[1]s ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/%[1]s",
|
||||||
|
user)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("sudo command failed for user '%s': %w. "+
|
||||||
|
"Please ensure the user has sudo privileges or use root user instead", user, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cmd := installCmd(user, version)
|
cmd := installCmd(user, version)
|
||||||
|
|
||||||
fmt.Println("Downloading Uncloud install script:", installScriptURL)
|
fmt.Println("Downloading Uncloud install script:", installScriptURL)
|
||||||
@@ -56,3 +87,67 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func promptResetMachine() error {
|
||||||
|
if !tui.IsStdinTerminal() {
|
||||||
|
return errors.New("the remote machine is already initialised as a cluster member; " +
|
||||||
|
"cannot ask to confirm reset in non-interactive mode, " +
|
||||||
|
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirm bool
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title(
|
||||||
|
"The remote machine is already initialised as a cluster member. Do you want to reset it first?\n" +
|
||||||
|
"This will:\n" +
|
||||||
|
"- Remove all service containers from the machine\n" +
|
||||||
|
"- Reset the machine to the uninitialised state",
|
||||||
|
).
|
||||||
|
Affirmative("Yes!").
|
||||||
|
Negative("No").
|
||||||
|
Value(&confirm),
|
||||||
|
),
|
||||||
|
).WithAccessible(true)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return fmt.Errorf("prompt user to confirm: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !confirm {
|
||||||
|
return fmt.Errorf("remote machine is already initialised as a cluster member")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAndWaitMachine(ctx context.Context, machineClient pb.MachineClient) error {
|
||||||
|
if _, err := machineClient.Reset(ctx, &pb.ResetRequest{}); err != nil {
|
||||||
|
return fmt.Errorf("reset remote machine: %w. You can also manually run 'uncloud-uninstall' "+
|
||||||
|
"on the remote machine to fully uninstall Uncloud from it", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Resetting the remote machine...")
|
||||||
|
if err := waitMachineReady(ctx, machineClient, 1*time.Minute); err != nil {
|
||||||
|
return fmt.Errorf("wait for machine to be ready after reset: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitMachineReady waits for the machine to be ready to serve requests.
|
||||||
|
func waitMachineReady(ctx context.Context, machineClient pb.MachineClient, timeout time.Duration) error {
|
||||||
|
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||||
|
backoff.WithMaxInterval(1*time.Second),
|
||||||
|
backoff.WithMaxElapsedTime(timeout),
|
||||||
|
), ctx)
|
||||||
|
|
||||||
|
inspect := func() error {
|
||||||
|
_, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect machine: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return backoff.Retry(inspect, boff)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import "github.com/charmbracelet/huh"
|
|
||||||
|
|
||||||
func Confirm() (bool, error) {
|
|
||||||
var confirmed bool
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title(
|
|
||||||
"Do you want to continue?",
|
|
||||||
).
|
|
||||||
Affirmative("Yes!").
|
|
||||||
Negative("No").
|
|
||||||
Value(&confirmed),
|
|
||||||
),
|
|
||||||
).WithAccessible(true)
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return confirmed, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PrintWarning(msg string) {
|
||||||
|
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
|
||||||
|
fmt.Fprintln(os.Stderr, styledMsg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"charm.land/huh/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Confirm shows a confirmation prompt with a yellow-styled title.
|
||||||
|
// If title is empty, it defaults to "Do you want to continue?".
|
||||||
|
func Confirm(title string) (bool, error) {
|
||||||
|
if title == "" {
|
||||||
|
title = "Do you want to continue?"
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmed bool
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title(title).
|
||||||
|
Affirmative("Yes!").
|
||||||
|
Negative("No").
|
||||||
|
Value(&confirmed),
|
||||||
|
),
|
||||||
|
).WithTheme(ThemeConfirm()).
|
||||||
|
WithAccessible(true)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemeConfirm returns a huh theme with a bold yellow title style for the confirmation prompt.
|
||||||
|
func ThemeConfirm() huh.Theme {
|
||||||
|
return huh.ThemeFunc(func(isDark bool) *huh.Styles {
|
||||||
|
t := huh.ThemeBase(isDark)
|
||||||
|
t.Focused.Title = t.Focused.Title.Foreground(lipgloss.Yellow).Bold(true)
|
||||||
|
return t
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsStdinTerminal checks if the standard input is a terminal (TTY).
|
||||||
|
func IsStdinTerminal() bool {
|
||||||
|
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsStdoutTerminal checks if the standard output is a terminal (TTY).
|
||||||
|
func IsStdoutTerminal() bool {
|
||||||
|
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Faint = lipgloss.NewStyle().Faint(true)
|
||||||
|
Red = lipgloss.NewStyle().Foreground(lipgloss.Red)
|
||||||
|
Green = lipgloss.NewStyle().Foreground(lipgloss.Green)
|
||||||
|
Yellow = lipgloss.NewStyle().Foreground(lipgloss.Yellow)
|
||||||
|
|
||||||
|
Bold = lipgloss.NewStyle().Bold(true)
|
||||||
|
BoldRed = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Red)
|
||||||
|
BoldGreen = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Green)
|
||||||
|
BoldYellow = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Yellow)
|
||||||
|
|
||||||
|
NameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("152"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatImage renders an image reference with the given style, using a faint colon separator for tagged images.
|
||||||
|
// Returns the styled original string if parsing fails.
|
||||||
|
func FormatImage(image string, style lipgloss.Style) string {
|
||||||
|
ref, err := reference.ParseDockerRef(image)
|
||||||
|
if err != nil {
|
||||||
|
return style.Render(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagged, ok := ref.(reference.NamedTagged); ok {
|
||||||
|
return style.Render(reference.FamiliarName(ref)) +
|
||||||
|
Faint.Render(":") +
|
||||||
|
style.Render(tagged.Tag())
|
||||||
|
}
|
||||||
|
return style.Render(reference.FamiliarString(ref))
|
||||||
|
}
|
||||||
@@ -5,14 +5,15 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/cenkalti/backoff/v4"
|
|
||||||
"golang.org/x/net/http2"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/cenkalti/backoff/v4"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChangeType string
|
type ChangeType string
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package daemon
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
systemd "github.com/coreos/go-systemd/daemon"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
|
systemd "github.com/coreos/go-systemd/daemon"
|
||||||
"github.com/psviderski/uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
package daemon
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
|
||||||
"net/netip"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
|
|
||||||
// TODO: ideally, this should be an RPC call to the daemon API to ensure the config is created and up-to-date.
|
|
||||||
func MachineToken(dataDir string) (machine.Token, error) {
|
|
||||||
state, err := machine.ParseState(machine.StatePath(dataDir))
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return machine.Token{}, fmt.Errorf("load machine config (is uncloudd daemon running?): %w", err)
|
|
||||||
}
|
|
||||||
return machine.Token{}, fmt.Errorf("load machine config: %w", err)
|
|
||||||
}
|
|
||||||
if len(state.Network.PublicKey) == 0 {
|
|
||||||
return machine.Token{}, errors.New("public key is not set in machine config")
|
|
||||||
}
|
|
||||||
|
|
||||||
ips, err := network.ListRoutableIPs()
|
|
||||||
if err != nil {
|
|
||||||
return machine.Token{}, fmt.Errorf("list routable addresses: %w", err)
|
|
||||||
}
|
|
||||||
publicIP, err := network.GetPublicIP()
|
|
||||||
// Ignore the error if failed to get the public IP using API services.
|
|
||||||
if err == nil {
|
|
||||||
ips = append(ips, publicIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoints := make([]netip.AddrPort, len(ips))
|
|
||||||
for i, ip := range ips {
|
|
||||||
endpoints[i] = netip.AddrPortFrom(ip, network.WireGuardPort)
|
|
||||||
}
|
|
||||||
return machine.NewToken(state.Network.PublicKey, publicIP, endpoints), nil
|
|
||||||
}
|
|
||||||
+1
-1
@@ -26,7 +26,7 @@ type RecordResponse struct {
|
|||||||
type AuthErrorResponse struct {
|
type AuthErrorResponse struct {
|
||||||
Status int `json:"status,omitempty"`
|
Status int `json:"status,omitempty"`
|
||||||
Message string `json:"msg,omitempty"`
|
Message string `json:"msg,omitempty"`
|
||||||
Data authErrorData `json:"data,omitempty"`
|
Data authErrorData `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type authErrorData struct {
|
type authErrorData struct {
|
||||||
|
|||||||
@@ -4,13 +4,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/cenkalti/backoff/v4"
|
|
||||||
"github.com/docker/docker/client"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
*client.Client
|
||||||
|
}
|
||||||
|
|
||||||
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
||||||
|
// TODO(lhf): convert into a Client method.
|
||||||
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
|
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
|
||||||
// Retry to ping the Docker daemon until it's ready or the context is canceled.
|
// Retry to ping the Docker daemon until it's ready or the context is canceled.
|
||||||
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user