mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
Compare commits
511
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da09d22b47 | ||
|
|
37f543b905 | ||
|
|
e5f96509bf | ||
|
|
2f35ac6498 | ||
|
|
f75afa0cec | ||
|
|
0f38b128fa | ||
|
|
535b91fe52 | ||
|
|
5c8beb8bd3 | ||
|
|
2b4da1e6fe | ||
|
|
be8b4f079f | ||
|
|
59b96cc01f | ||
|
|
4c77fe2cfd | ||
|
|
3b89af4a11 | ||
|
|
1abadaef50 | ||
|
|
3b1ce42dd7 | ||
|
|
1640b4c433 | ||
|
|
44549c00fd | ||
|
|
26f34df3e6 | ||
|
|
89fb9efcfa | ||
|
|
638f320959 | ||
|
|
3f2e60ea74 | ||
|
|
8e0a24e781 | ||
|
|
e3e39dde32 | ||
|
|
fc61e2fc07 | ||
|
|
2a0d31cec0 | ||
|
|
2f55af0cab | ||
|
|
971b21d6c1 | ||
|
|
b651d95183 | ||
|
|
d45c1b6869 | ||
|
|
a5fe9fbfb2 | ||
|
|
4590a516ad | ||
|
|
5f9ed6dacf | ||
|
|
e97497d737 | ||
|
|
b4bf1d17d0 | ||
|
|
cfb4693286 | ||
|
|
fd3facb6b0 | ||
|
|
bd03bb9bfa | ||
|
|
eec728620b | ||
|
|
d2dd5e3c89 | ||
|
|
2bfcce7803 | ||
|
|
1ec5184f21 | ||
|
|
23dfe45b2f | ||
|
|
d9e906a77e | ||
|
|
51f7ebf3bf | ||
|
|
ae04dea808 | ||
|
|
009c0e1b4c | ||
|
|
18d38e96b4 | ||
|
|
12c86f1dd4 | ||
|
|
4df1820f96 | ||
|
|
6f9ac1fcc7 | ||
|
|
6a98acbe79 | ||
|
|
b0059af9b1 | ||
|
|
7edd8f2976 | ||
|
|
735e36012a | ||
|
|
69ebce57e9 | ||
|
|
365a5fc0b5 | ||
|
|
624caf037b | ||
|
|
ff1a8fbcf9 | ||
|
|
3aa8fead76 | ||
|
|
4075f85b72 | ||
|
|
59f6fa75ca | ||
|
|
8b66a11394 | ||
|
|
fa3d992dbc | ||
|
|
a7d8afb052 | ||
|
|
0d820bc75a | ||
|
|
6a4e63dbd4 | ||
|
|
7699b530c2 | ||
|
|
f1abbbea6d | ||
|
|
683ad22358 | ||
|
|
8f00e0082e | ||
|
|
c34542a9d8 | ||
|
|
88db1cb6a9 | ||
|
|
dfa6e001c5 | ||
|
|
f49ec465d0 | ||
|
|
1ffe9acdd7 | ||
|
|
e35e69b4be | ||
|
|
11c2e641f1 | ||
|
|
bb97537df7 | ||
|
|
9f4b8179b5 | ||
|
|
f7b079c4f2 | ||
|
|
3648187219 | ||
|
|
93346301fb | ||
|
|
cc86c8660c | ||
|
|
2963436538 | ||
|
|
6e326277bb | ||
|
|
469d6fadcd | ||
|
|
5f214247ca | ||
|
|
0b8495e964 | ||
|
|
6068e24f4f | ||
|
|
7861d56dff | ||
|
|
7bc69b9a9e | ||
|
|
38800924ed | ||
|
|
cf7e333579 | ||
|
|
769f5e47c3 | ||
|
|
6e1165ec57 | ||
|
|
8d16da596c | ||
|
|
d6c06828b3 | ||
|
|
fe93d91894 | ||
|
|
3f70018e7c | ||
|
|
70e9385032 | ||
|
|
db3016fe89 | ||
|
|
c56385602e | ||
|
|
fbbf839258 | ||
|
|
eee28e2ead | ||
|
|
ef57b62625 | ||
|
|
e854bbb3e1 | ||
|
|
938e7d05c0 | ||
|
|
6c2759f315 | ||
|
|
72bf34632f | ||
|
|
7db8bba6ea | ||
|
|
badb8ecd3c | ||
|
|
9413f1c0b1 | ||
|
|
d8818ba770 | ||
|
|
440215b811 | ||
|
|
02a418aafb | ||
|
|
33550be65e | ||
|
|
edad2429c5 | ||
|
|
b605a64fbe | ||
|
|
16c876d83f | ||
|
|
d6ff62159d | ||
|
|
fcf164dda1 | ||
|
|
e5957d9d6b | ||
|
|
143e68cc95 | ||
|
|
835c310987 | ||
|
|
5fce84d33c | ||
|
|
45497fd20c | ||
|
|
f08d9fe405 | ||
|
|
565a8d8366 | ||
|
|
343357adf9 | ||
|
|
8ee551ca8f | ||
|
|
948201cb6f | ||
|
|
f71fc57f34 | ||
|
|
1acb0ff110 | ||
|
|
03c2681792 | ||
|
|
f3cb6657ae | ||
|
|
4532b985d4 | ||
|
|
000a8a1d3e | ||
|
|
7705db2c4f | ||
|
|
590a1b231f | ||
|
|
8709ac0ed5 | ||
|
|
f8ebd94a79 | ||
|
|
87331a9265 | ||
|
|
2f5a2c6344 | ||
|
|
97e032cb7b | ||
|
|
410a95ec88 | ||
|
|
df4ab40e12 | ||
|
|
c6205d4f57 | ||
|
|
13dea92311 | ||
|
|
9100c22e7f | ||
|
|
6125553a97 | ||
|
|
c9452f1509 | ||
|
|
25b173128a | ||
|
|
4e40a93991 | ||
|
|
9fc54c9d64 | ||
|
|
cacfca7a88 | ||
|
|
b3885b0888 | ||
|
|
3ada89c4f8 | ||
|
|
20b2ca2ea5 | ||
|
|
1bbefdd0d2 | ||
|
|
f467fad84c | ||
|
|
0e80f2e5b2 | ||
|
|
64f6a4f3d3 | ||
|
|
0975cbab66 | ||
|
|
615022c9ac | ||
|
|
ecbd4378e4 | ||
|
|
1becd033e6 | ||
|
|
1cba1eef0a | ||
|
|
3893005866 | ||
|
|
1911e03c82 | ||
|
|
641b11a92d | ||
|
|
7c7530f977 | ||
|
|
6e7fe405ef | ||
|
|
03c5c054e1 | ||
|
|
c42968c812 | ||
|
|
ffef41081b | ||
|
|
58b51b974c | ||
|
|
2d923380a3 | ||
|
|
3910101a88 | ||
|
|
7a9d0cf9db | ||
|
|
042dd594e0 | ||
|
|
1471a94162 | ||
|
|
b1abd07dde | ||
|
|
3757813b20 | ||
|
|
f1a150821c | ||
|
|
874af1ba10 | ||
|
|
2ad098c612 | ||
|
|
4172cb826f | ||
|
|
041d51b74f | ||
|
|
fb01604d21 | ||
|
|
bd8bcbd2f9 | ||
|
|
07501813c8 | ||
|
|
1bbcfa8519 | ||
|
|
e8715fce38 | ||
|
|
a0a66f34c4 | ||
|
|
b799b7518e | ||
|
|
2571498ed2 | ||
|
|
5bcbfc332d | ||
|
|
34574e41fc | ||
|
|
c7355afa3d | ||
|
|
a907fdb1af | ||
|
|
c9ba4adccb | ||
|
|
a0551d2d64 | ||
|
|
76129ea056 | ||
|
|
6ffd35f596 | ||
|
|
0ef776e7f1 | ||
|
|
0b22b2136a | ||
|
|
e1530bb6f3 | ||
|
|
7fc07ce4bd | ||
|
|
eb42082955 | ||
|
|
8719e37856 | ||
|
|
e1cc39b0d5 | ||
|
|
efe646f54c | ||
|
|
e130d803e4 | ||
|
|
56e7e8baa2 | ||
|
|
b963ef39d1 | ||
|
|
1df0fa290f | ||
|
|
c3bb0c9f49 | ||
|
|
cf65cecd9e | ||
|
|
fff17dbb60 | ||
|
|
586a4fa129 | ||
|
|
2a82f75cad | ||
|
|
805afd0d28 | ||
|
|
11098ea7d1 | ||
|
|
90e357a1dc | ||
|
|
91d58bc701 | ||
|
|
69d3a511d6 | ||
|
|
c3123820b2 | ||
|
|
2e49d94fad | ||
|
|
bb51e01c5c | ||
|
|
cdb2884e4e | ||
|
|
137abde808 | ||
|
|
342969b7f4 | ||
|
|
d9e350c4dd | ||
|
|
3f4f263c05 | ||
|
|
0760ba3b18 | ||
|
|
0896bd27fb | ||
|
|
a16b2d1c74 | ||
|
|
52117954a8 | ||
|
|
435a794fa5 | ||
|
|
9642863d7b | ||
|
|
6bb9bdeb5a | ||
|
|
30f1e135f1 | ||
|
|
58ecc17ac1 | ||
|
|
4c239ed79a | ||
|
|
968dd50705 | ||
|
|
d7f437107d | ||
|
|
72548fbd1b | ||
|
|
a5f6ec4a68 | ||
|
|
4cc68e3133 | ||
|
|
2bacebe900 | ||
|
|
09bea1d12d | ||
|
|
5e9b883ad5 | ||
|
|
1046396edc | ||
|
|
35804458b0 | ||
|
|
6bb64ac5dc | ||
|
|
e5f68947f9 | ||
|
|
289d964b2a | ||
|
|
d0fd9db258 | ||
|
|
0ea997e826 | ||
|
|
2d84059d7f | ||
|
|
ecec42ea60 | ||
|
|
af4629b679 | ||
|
|
f6cf5cc250 | ||
|
|
f37bfd01fe | ||
|
|
d9897e5d1e | ||
|
|
bcb67a4073 | ||
|
|
bcb5a2a173 | ||
|
|
c3b88077ac | ||
|
|
98e1bdc9b5 | ||
|
|
e13408d79f | ||
|
|
8610322682 | ||
|
|
0be42f1f59 | ||
|
|
dcbfcbd0fb | ||
|
|
d0e46186ed | ||
|
|
625a8baed3 | ||
|
|
98db5ed440 | ||
|
|
c4398f9f06 | ||
|
|
a53fa2c1d8 | ||
|
|
5e2b2ac836 | ||
|
|
43f28284f1 | ||
|
|
b17fd7531f | ||
|
|
9859be50a6 | ||
|
|
f209851986 | ||
|
|
4da51636fd | ||
|
|
76a9f8e8b6 | ||
|
|
a477db6b16 | ||
|
|
04fd1b1425 | ||
|
|
c0bf1f2930 | ||
|
|
0bf759333a | ||
|
|
b482d836aa | ||
|
|
3d2d9b78e8 | ||
|
|
99219dc376 | ||
|
|
408703360c | ||
|
|
ac48b8bee6 | ||
|
|
e3a310397b | ||
|
|
b2f7c6fb33 | ||
|
|
653f47f507 | ||
|
|
5b66cec627 | ||
|
|
0d9a2b6e07 | ||
|
|
fb68303825 | ||
|
|
cfe5b018f3 | ||
|
|
b451f2cf7c | ||
|
|
7d25bbafdf | ||
|
|
58e1c2eea3 | ||
|
|
6c791840bd | ||
|
|
3af97306e1 | ||
|
|
d645958631 | ||
|
|
48bc94828e | ||
|
|
a43f4bf5df | ||
|
|
22f705d6c9 | ||
|
|
efcb9ee947 | ||
|
|
a347b32b65 | ||
|
|
7e865b61c2 | ||
|
|
b959f5d9f3 | ||
|
|
df64a00813 | ||
|
|
07dc5c373d | ||
|
|
a6e8616b36 | ||
|
|
7041244728 | ||
|
|
bee101aa3e | ||
|
|
21e70dd846 | ||
|
|
91f870989a | ||
|
|
bda1123ae6 | ||
|
|
190d0de61f | ||
|
|
f2e730796f | ||
|
|
bb77ff9246 | ||
|
|
5061097dff | ||
|
|
911522c388 | ||
|
|
3ee7cb6e8e | ||
|
|
2e00632959 | ||
|
|
8e53fd46fa | ||
|
|
2835edadeb | ||
|
|
f7c4490c10 | ||
|
|
b3102480c0 | ||
|
|
cbe7c563cd | ||
|
|
b016252821 | ||
|
|
6c197085d0 | ||
|
|
c29fa571e1 | ||
|
|
092fe254aa | ||
|
|
6951221b88 | ||
|
|
fa54584128 | ||
|
|
d922f7bfed | ||
|
|
18ec186e81 | ||
|
|
2a898b3eee | ||
|
|
042b44cbb6 | ||
|
|
3f7726e5fb | ||
|
|
2b3f325075 | ||
|
|
be2a26e626 | ||
|
|
4479490db2 | ||
|
|
7556f8cb12 | ||
|
|
bfbcec34e8 | ||
|
|
8eea34d8dc | ||
|
|
6b80ef9db8 | ||
|
|
2134f3c1d9 | ||
|
|
0652123bc7 | ||
|
|
4de7ecd747 | ||
|
|
e8908ced9e | ||
|
|
deedbcb8a9 | ||
|
|
8bfb3b80ed | ||
|
|
848abbd2f8 | ||
|
|
9cb4b35546 | ||
|
|
26df6740e3 | ||
|
|
537789902b | ||
|
|
4088fdfd84 | ||
|
|
01a71b4b38 | ||
|
|
73d41c75b3 | ||
|
|
f85b5dd01d | ||
|
|
1fb8a3d7a0 | ||
|
|
3ce9c8be04 | ||
|
|
3ac166e6cb | ||
|
|
88ce2d5181 | ||
|
|
8bddbec7bd | ||
|
|
a40fd613e8 | ||
|
|
99c9aabdfd | ||
|
|
5504951a8a | ||
|
|
2727c3c97e | ||
|
|
7d47d274a0 | ||
|
|
a4247b0097 | ||
|
|
11612a802c | ||
|
|
d7bad5ed46 | ||
|
|
c0dff56378 | ||
|
|
00dbbec62b | ||
|
|
9755a6973d | ||
|
|
16120b80ba | ||
|
|
386e4e25f3 | ||
|
|
43ddb5c363 | ||
|
|
417427b4d5 | ||
|
|
5da1fc5387 | ||
|
|
5621a84e99 | ||
|
|
8db7ac84a0 | ||
|
|
77890b8a8a | ||
|
|
daada3bbe9 | ||
|
|
21d55dc69a | ||
|
|
6eb373e4de | ||
|
|
6a4a7f2e6a | ||
|
|
349dd0d2ef | ||
|
|
cf5818d963 | ||
|
|
faf3378659 | ||
|
|
bb967461d0 | ||
|
|
0b5dd2f7ba | ||
|
|
d5f0bca4a0 | ||
|
|
bdd034d169 | ||
|
|
48767125f4 | ||
|
|
035f44896d | ||
|
|
3d5fe0a824 | ||
|
|
6d1c3aa5b2 | ||
|
|
cd60d5178f | ||
|
|
7aa7a9fea1 | ||
|
|
73785007cb | ||
|
|
24a824b890 | ||
|
|
085ee285e8 | ||
|
|
6daa1291b5 | ||
|
|
c3d3a4242e | ||
|
|
871fd8c035 | ||
|
|
a6ccd6055a | ||
|
|
ca607ee82d | ||
|
|
1c8e60aac0 | ||
|
|
d02fc4d981 | ||
|
|
410f74425c | ||
|
|
6e0df53f13 | ||
|
|
e9d07687f2 | ||
|
|
95175d10c5 | ||
|
|
df25b0d098 | ||
|
|
5c1a3dd694 | ||
|
|
6d39174b51 | ||
|
|
e851f25b12 | ||
|
|
3c1d2201ce | ||
|
|
b4c2ec3a2a | ||
|
|
bf10b21da5 | ||
|
|
2819ba63aa | ||
|
|
54a8b09c9f | ||
|
|
24371eb401 | ||
|
|
9c9e143dc8 | ||
|
|
19d53c4f67 | ||
|
|
40f929847c | ||
|
|
e73e38957a | ||
|
|
e82b78df49 | ||
|
|
741378d0b7 | ||
|
|
d31595820d | ||
|
|
035601d366 | ||
|
|
5c96f75958 | ||
|
|
2c62e1f6df | ||
|
|
22bca16226 | ||
|
|
a4be45186d | ||
|
|
5e79697505 | ||
|
|
6736839ccb | ||
|
|
913fb2da0c | ||
|
|
bd0e7f44b2 | ||
|
|
81cf5f5605 | ||
|
|
cedafd069d | ||
|
|
93dfa5ca9d | ||
|
|
c70c8a6ec1 | ||
|
|
5c74b09f45 | ||
|
|
1316dd3ffc | ||
|
|
4493bb3c27 | ||
|
|
b23699470b | ||
|
|
34316c77cf | ||
|
|
c2b466b046 | ||
|
|
d36429e6e5 | ||
|
|
17b96a8744 | ||
|
|
a54666a7ac | ||
|
|
5cb765d286 | ||
|
|
5bf3cba2d6 | ||
|
|
8a3f0032ae | ||
|
|
5fe77c688b | ||
|
|
61dc97228d | ||
|
|
e6df331fb8 | ||
|
|
9dbcf1e902 | ||
|
|
2f6c988125 | ||
|
|
411c3398ce | ||
|
|
3a644771ca | ||
|
|
2eff7d1e22 | ||
|
|
f06dd865fa | ||
|
|
ee580df6af | ||
|
|
2c0cf28509 | ||
|
|
7b98ed34df | ||
|
|
5094149406 | ||
|
|
2fd1ee203a | ||
|
|
336bdf9f4b | ||
|
|
c80c75e4e1 | ||
|
|
e5bda92ec6 | ||
|
|
fd3ad91136 | ||
|
|
94186b2ae7 | ||
|
|
1bcb8836ef | ||
|
|
840943b3b0 | ||
|
|
518eae9bd2 | ||
|
|
06a4001080 | ||
|
|
b73577e1a0 | ||
|
|
c3ba43a8fa | ||
|
|
c275273617 | ||
|
|
b688742e5a | ||
|
|
4ec87db8e8 | ||
|
|
c6358474ac | ||
|
|
8fbf406007 | ||
|
|
e123f7214e | ||
|
|
3287691a31 | ||
|
|
6f9fc69c2e | ||
|
|
0effba0adb | ||
|
|
667ecadd30 | ||
|
|
68845e7607 | ||
|
|
6d4698fc1d | ||
|
|
708e14a4e0 | ||
|
|
c80de16ebe | ||
|
|
3e9718f08c | ||
|
|
69c9167355 | ||
|
|
b25938acc1 | ||
|
|
1ec3376be5 | ||
|
|
aa236aac10 | ||
|
|
9fcc319c3a | ||
|
|
2f7463ebe3 | ||
|
|
498d35e866 | ||
|
|
ba67b96734 |
@@ -0,0 +1,12 @@
|
||||
# Ignore everything and use an allowlist of what is allowed to not package any secrets by accident.
|
||||
*
|
||||
|
||||
# Allow files and directories.
|
||||
!cmd/
|
||||
!internal/
|
||||
!pkg/
|
||||
!scripts/docker/
|
||||
!go.*
|
||||
|
||||
# Ignore unnecessary files inside allowed directories.
|
||||
**/.DS_Store
|
||||
@@ -0,0 +1,20 @@
|
||||
# EditorConfig is awesome: https://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
# Default settings for all files
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# Settings for Go files
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
# Settings for Bash scripts
|
||||
[*.sh]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
@@ -0,0 +1 @@
|
||||
github: [psviderski]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 525 KiB |
@@ -0,0 +1,67 @@
|
||||
name: Go Tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "test/**"
|
||||
- "release/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/**"
|
||||
- "**.go"
|
||||
- "**.proto"
|
||||
- "go.*"
|
||||
- "Makefile"
|
||||
- "scripts/**"
|
||||
- "test/**"
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.23.2"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
go mod tidy
|
||||
git diff --exit-code ||
|
||||
(echo "go.mod or go.sum has changed. Please run 'go mod tidy' and commit the changes." && exit 1)
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
timeout-minutes: 10
|
||||
|
||||
check-protobuf:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
# Run on more platforms so that mise lockfile checksums are checked/generated for all platforms.
|
||||
# See https://github.com/jdx/mise/issues/4276 for details.
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Mise
|
||||
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
|
||||
with:
|
||||
version: "2025.6.5"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Generate Protobuf files
|
||||
run: |
|
||||
make proto
|
||||
# check if the generated files are up to date
|
||||
git diff --exit-code ||
|
||||
(echo "Generated protobuf files are not up to date. Please run 'make proto' and commit the changes." && exit 1)
|
||||
+13
-2
@@ -8,6 +8,9 @@
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# OS X
|
||||
.DS_Store
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
@@ -24,5 +27,13 @@ go.work.sum
|
||||
.env
|
||||
.idea/
|
||||
|
||||
data
|
||||
dist/
|
||||
/data/
|
||||
/dist/
|
||||
/uc
|
||||
/tmp
|
||||
|
||||
# Web
|
||||
node_modules/
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
|
||||
+27
-4
@@ -24,6 +24,8 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w -X github.com/psviderski/uncloud/internal/version.version={{ .Version }}
|
||||
|
||||
- id: uncloudd
|
||||
main: ./cmd/uncloudd
|
||||
@@ -35,28 +37,34 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w -X github.com/psviderski/uncloud/internal/version.version={{ .Version }}
|
||||
|
||||
archives:
|
||||
- id: uncloud
|
||||
builds:
|
||||
- uncloud
|
||||
format: gz
|
||||
formats:
|
||||
# gz is not compatible with homebrew releases, so we use tar.gz
|
||||
- tar.gz
|
||||
name_template: >-
|
||||
{{ .Binary }}_
|
||||
{{- if eq .Os "darwin"}}macos
|
||||
{{- else}}{{.Os}}{{end}}_
|
||||
{{- .Arch }}"
|
||||
{{- .Arch }}
|
||||
# use zip for windows archives
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats:
|
||||
- zip
|
||||
files:
|
||||
- none*
|
||||
|
||||
- id: uncloudd
|
||||
builds:
|
||||
- uncloudd
|
||||
format: gz
|
||||
formats:
|
||||
- tar.gz
|
||||
name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}"
|
||||
files:
|
||||
- none*
|
||||
@@ -70,3 +78,18 @@ changelog:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
brews:
|
||||
- name: uncloud
|
||||
description: "Uncloud CLI"
|
||||
homepage: "https://uncloud.run"
|
||||
ids:
|
||||
- uncloud
|
||||
install: |
|
||||
bin.install "uncloud"
|
||||
bin.install_symlink "uncloud" => "uc"
|
||||
skip_upload: false
|
||||
repository:
|
||||
owner: psviderski
|
||||
name: homebrew-tap
|
||||
token: "{{ .Env.GITHUB_TOKEN_HOMEBREW_TAP }}"
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
[tools.go]
|
||||
version = "1.23.10"
|
||||
backend = "core:go"
|
||||
|
||||
[tools.go.checksums]
|
||||
"go1.23.10.darwin-arm64.tar.gz" = "sha256:25c64bfa8a8fd8e7f62fb54afa4354af8409a4bb2358c2699a1003b733e6fce5"
|
||||
"go1.23.10.linux-amd64.tar.gz" = "sha256:535f9f81802499f2a7dbfa70abb8fda3793725fcc29460f719815f6e10b5fd60"
|
||||
|
||||
[tools.protoc]
|
||||
version = "27.3"
|
||||
backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||
|
||||
[tools.protoc.checksums]
|
||||
"protoc-27.3-linux-x86_64.zip" = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
|
||||
"protoc-27.3-osx-aarch_64.zip" = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
|
||||
|
||||
[tools.protoc-gen-go]
|
||||
version = "1.34.2"
|
||||
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
|
||||
|
||||
[tools.protoc-gen-go.checksums]
|
||||
"protoc-gen-go.v1.34.2.darwin.arm64.tar.gz" = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
|
||||
"protoc-gen-go.v1.34.2.linux.amd64.tar.gz" = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
|
||||
|
||||
[tools.protoc-gen-go-grpc]
|
||||
version = "1.5.1"
|
||||
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
|
||||
|
||||
[tools.protoc-gen-go-grpc.checksums]
|
||||
"protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz" = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
|
||||
"protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz" = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
|
||||
+7
-1
@@ -1,5 +1,11 @@
|
||||
[settings]
|
||||
experimental = true
|
||||
|
||||
[tools]
|
||||
go = "1.22"
|
||||
go = "1.23"
|
||||
protoc = "27.3"
|
||||
protoc-gen-go = "1.34.2"
|
||||
protoc-gen-go-grpc = "1.5.1"
|
||||
|
||||
[env]
|
||||
_.file = ".env"
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
ARG ALPINE_VERSION=3.20.3
|
||||
|
||||
FROM golang:1.23.2-alpine AS uncloudd
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
WORKDIR /build
|
||||
# Download and cache dependencies and only redownload them in subsequent builds if they change.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download && go mod verify
|
||||
|
||||
COPY . .
|
||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd cmd/uncloudd/main.go
|
||||
|
||||
|
||||
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN CORROSION_ARCH=$(case "${TARGETARCH}" in \
|
||||
"amd64") echo "x86_64" ;; \
|
||||
"arm64") echo "aarch64" ;; \
|
||||
*) echo "Architecture '${TARGETARCH}' not supported" >&2 && exit 1 ;; \
|
||||
esac) \
|
||||
&& wget -q -O /tmp/corrosion.tar.gz \
|
||||
"https://github.com/psviderski/corrosion/releases/latest/download/corrosion-${CORROSION_ARCH}-unknown-linux-gnu.tar.gz" \
|
||||
&& tar -xzf /tmp/corrosion.tar.gz -C /tmp \
|
||||
&& install /tmp/corrosion /usr/local/bin/corrosion \
|
||||
&& rm /tmp/corrosion.tar.gz /tmp/corrosion
|
||||
|
||||
# Beware that more modern images like chainguard/wolfi-base build glibc with flags that require newer CPU features,
|
||||
# e.g. x86-64-v2. So such image may fail with "Fatal glibc error: CPU does not support x86-64-v2" on older CPUs.
|
||||
FROM gcr.io/distroless/cc-debian12:latest AS corrosion
|
||||
COPY --from=corrosion-download /usr/local/bin/corrosion /usr/local/bin/corrosion
|
||||
CMD ["corrosion", "agent"]
|
||||
|
||||
|
||||
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
|
||||
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
|
||||
|
||||
RUN apk --no-cache add crane
|
||||
RUN crane pull "${CORROSION_IMAGE}" /corrosion.tar
|
||||
|
||||
|
||||
# Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker.
|
||||
FROM docker:27.3.1-dind AS ucind
|
||||
# Create system group and user 'uncloud'.
|
||||
RUN addgroup -S uncloud && adduser -SHD -h /nonexistent -G uncloud -g "" uncloud
|
||||
RUN apk --no-cache add \
|
||||
socat \
|
||||
wireguard-tools
|
||||
|
||||
COPY --from=corrosion-image-tarball /corrosion.tar /images/corrosion.tar
|
||||
COPY scripts/docker/dind scripts/docker/entrypoint.sh /usr/local/bin/
|
||||
COPY --from=uncloudd /build/uncloudd /usr/local/bin/
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["uncloudd"]
|
||||
@@ -1,16 +1,94 @@
|
||||
.PHONY: build
|
||||
uncloud-dev:
|
||||
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
|
||||
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
|
||||
DOCS_IMAGE ?= ghcr.io/psviderski/uncloud-docs: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
|
||||
GOOS=linux GOARCH=amd64 go build -o uncloud-linux-amd64 ./cmd/uncloud && \
|
||||
scp uncloud-linux-amd64 spy@192.168.40.243:~/ && \
|
||||
ssh spy@192.168.40.243 sudo install ./uncloud-linux-amd64 /usr/local/bin/uncloud
|
||||
scp uncloud-linux-amd64 spy@192.168.40.176:~/ && \
|
||||
ssh spy@192.168.40.176 sudo install ./uncloud-linux-amd64 /usr/local/bin/uncloud
|
||||
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
|
||||
ucind-cluster:
|
||||
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
|
||||
|
||||
.PHONY: proto
|
||||
proto:
|
||||
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative internal/machine/api/pb/*.proto
|
||||
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||
--proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto
|
||||
|
||||
.PHONY: corrosion-image
|
||||
corrosion-image:
|
||||
docker build -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||
|
||||
.PHONY: corrosion-multiarch-image-push
|
||||
corrosion-multiarch-image-push:
|
||||
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||
|
||||
.PHONY: ucind-image
|
||||
ucind-image:
|
||||
docker build -t "$(UCIND_IMAGE)" --target ucind .
|
||||
|
||||
.PHONY: ucind-multiarch-image-push
|
||||
ucind-multiarch-image-push:
|
||||
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
ifeq ($(TEST_NAME),)
|
||||
go test -count=1 -v ./...
|
||||
else
|
||||
go test -count=1 -v -run ^$(TEST_NAME)$$ ./...
|
||||
endif
|
||||
|
||||
.PHONY: test-clean
|
||||
test-clean:
|
||||
@CONTAINERS=$$(docker ps --filter "name=ucind-test" -q); \
|
||||
if [ -n "$$CONTAINERS" ]; then \
|
||||
echo "Killing containers..."; \
|
||||
docker kill $$CONTAINERS; \
|
||||
fi; \
|
||||
CONTAINERS_STOPPED=$$(docker ps -a --filter "name=ucind-test" -q); \
|
||||
if [ -n "$$CONTAINERS_STOPPED" ]; then \
|
||||
echo "Removing stopped containers..."; \
|
||||
docker rm $$CONTAINERS_STOPPED; \
|
||||
else \
|
||||
echo "Nothing to clean."; \
|
||||
fi
|
||||
|
||||
.PHONY: vet
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
.PHONY: docs-image-push
|
||||
docs-image:
|
||||
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(DOCS_IMAGE)" ./docs
|
||||
|
||||
@@ -1,9 +1,332 @@
|
||||
# Uncloud
|
||||
<div align="center">
|
||||
<img src="./website/images/logo.svg" height="100" width="100" alt="Uncloud logo"/>
|
||||
<h1>Uncloud</h1>
|
||||
<p><strong>Docker simplicity. Multi-machine power.</strong></p>
|
||||
|
||||
Uncloud is a lightweight tool for deploying and managing containerised applications across a network of Docker hosts. It
|
||||
bridges the gap between simple Docker deployments and complex cloud platforms and container orchestrators like
|
||||
Kubernetes.
|
||||
<p>
|
||||
<a href="https://docs.uncloud.run"><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>
|
||||
|
||||
Our goal is to become the go-to self-hosted alternative to platforms like [Fly.io](https://fly.io) and
|
||||
[Render](https://render.com/). Whether you're using bare metal servers, Raspberry Pis, or cloud VMs, Uncloud transforms
|
||||
your hardware into a personal cloud platform.
|
||||
Uncloud is a lightweight clustering and container orchestration tool that lets you deploy and manage web apps across
|
||||
cloud VMs and bare metal with minimised cluster management overhead. It creates a secure WireGuard mesh network between
|
||||
your Docker hosts and provides automatic service discovery, load balancing, ingress with HTTPS, and simple CLI commands
|
||||
to manage your apps.
|
||||
|
||||
Unlike traditional orchestrators, there's no central control plane and quorum to maintain. Each machine maintains a
|
||||
synchronised copy of the cluster state through peer-to-peer communication, keeping cluster operations functional even if
|
||||
some machines go offline.
|
||||
|
||||
Uncloud is the solution for developers who want the flexibility of self-hosted infrastructure without the operational
|
||||
complexity of Kubernetes.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment —
|
||||
regardless of location or provider.
|
||||
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
|
||||
need to learn a new bespoke DSL.
|
||||
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
|
||||
soon.
|
||||
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
||||
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
|
||||
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
|
||||
IPs for direct cross-machine communication.
|
||||
* **No control plane**: Fully decentralised design eliminates single points of failure and reduces operational overhead.
|
||||
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
||||
model and troubleshooting.
|
||||
* **Managed DNS**: Automatic DNS records `*.<id>.cluster.uncloud.run` for services with public access via managed
|
||||
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
||||
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
||||
Encrypt.
|
||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
||||
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
||||
|
||||
### 🚀 Coming soon
|
||||
|
||||
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Push your Docker images directly to your
|
||||
machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
|
||||
|
||||
## 🎬 Quick demo
|
||||
|
||||
The screenshot below demonstrates how I use Uncloud to deploy the [Uncloud Documentation](https://docs.uncloud.run)
|
||||
website to 2 remote machines (why not?) from the [`compose.yaml`](docs/compose.yaml) file on my local machine.
|
||||
|
||||
It exposes the container port `8000/tcp` as HTTPS on the domain `docs.uncloud.run`, served by the Caddy reverse proxy on
|
||||
the remote machines. All managed by Uncloud.
|
||||
|
||||

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

|
||||
|
||||
<details>
|
||||
<summary>Peek under the hood to see what happens when you run certain commands.</summary>
|
||||
|
||||
**When you initialise a new cluster on a machine:**
|
||||
|
||||
```bash
|
||||
$ uc machine init --name oracle-vm ubuntu@152.67.101.197
|
||||
|
||||
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
|
||||
⏳ Running Uncloud install script...
|
||||
✓ Docker is already installed.
|
||||
⏳ Installing Docker...
|
||||
...
|
||||
✓ Docker installed successfully.
|
||||
✓ Linux user and group 'uncloud' created.
|
||||
✓ Linux user 'ubuntu' added to group 'uncloud'.
|
||||
⏳ Installing Uncloud binaries...
|
||||
⏳ Downloading uncloudd binary: https://github.com/psviderski/uncloud/releases/latest/download/uncloudd_linux_arm64.tar.gz
|
||||
✓ uncloudd binary installed: /usr/local/bin/uncloudd
|
||||
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
|
||||
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
|
||||
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
|
||||
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
|
||||
⏳ Downloading uncloud-corrosion binary: https://github.com/psviderski/corrosion/releases/latest/download/corrosion-aarch64-unknown-linux-gnu.tar.gz
|
||||
✓ uncloud-corrosion binary installed: /usr/local/bin/uncloud-corrosion
|
||||
✓ Systemd unit file created: /etc/systemd/system/uncloud-corrosion.service
|
||||
⏳ Starting Uncloud machine daemon (uncloud.service)...
|
||||
✓ Uncloud machine daemon started.
|
||||
✓ Uncloud installed on the machine successfully! 🎉
|
||||
Cluster "default" initialised with machine "oracle-vm"
|
||||
Waiting for the machine to be ready...
|
||||
|
||||
Reserved cluster domain: xuw3xd.cluster.uncloud.run
|
||||
[+] Deploying service caddy 1/1
|
||||
✔ Container caddy-c47x on oracle-vm Started 0.9s
|
||||
|
||||
Updating cluster domain records in Uncloud DNS to point to machines running caddy service...
|
||||
[+] Verifying internet access to caddy service 1/1
|
||||
✔ Machine oracle-vm (152.67.101.197) Reachable 0.1s
|
||||
|
||||
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||
*.xuw3xd.cluster.uncloud.run A → 152.67.101.197
|
||||
```
|
||||
|
||||
1. The CLI SSHs into the machine and installs Docker, the `uncloudd` machine daemon and
|
||||
[corrosion](https://github.com/superfly/corrosion) service, managed by systemd.
|
||||
2. Generates a unique WireGuard key pair, allocates a dedicated subnet `10.210.0.0/24` for the machine and its
|
||||
containers, and configures `uncloudd` accordingly. All subsequent communication happens with `uncloudd`
|
||||
through its gRPC API over SSH.
|
||||
3. Configures and starts `corrosion`, a CRDT-based distributed SQLite database to share cluster state between machines.
|
||||
4. Creates a Docker bridge network connected to the WireGuard interface.
|
||||
5. This machine becomes an entry point for the newly created cluster which is stored in the cluster config under
|
||||
`~/.config/uncloud` on your local machine.
|
||||
|
||||
**When you add another machine:**
|
||||
|
||||
```bash
|
||||
$ uc machine add --name hetzner-server root@5.223.45.199
|
||||
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
|
||||
⏳ Running Uncloud install script...
|
||||
✓ Docker is already installed.
|
||||
✓ Linux user and group 'uncloud' created.
|
||||
⏳ Installing Uncloud binaries...
|
||||
⏳ Downloading uncloudd binary: https://github.com/psviderski/uncloud/releases/latest/download/uncloudd_linux_amd64.tar.gz
|
||||
✓ uncloudd binary installed: /usr/local/bin/uncloudd
|
||||
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
|
||||
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
|
||||
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
|
||||
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
|
||||
⏳ Downloading uncloud-corrosion binary: https://github.com/psviderski/corrosion/releases/latest/download/corrosion-x86_64-unknown-linux-gnu.tar.gz
|
||||
✓ uncloud-corrosion binary installed: /usr/local/bin/uncloud-corrosion
|
||||
✓ Systemd unit file created: /etc/systemd/system/uncloud-corrosion.service
|
||||
⏳ Starting Uncloud machine daemon (uncloud.service)...
|
||||
✓ Uncloud machine daemon started.
|
||||
✓ Uncloud installed on the machine successfully! 🎉
|
||||
Machine "hetzner-server" added to cluster
|
||||
Waiting for the machine to be ready...
|
||||
|
||||
[+] Deploying service caddy 1/1
|
||||
✔ Container caddy-d36c on hetzner-server Started 1.0s
|
||||
|
||||
Updating cluster domain records in Uncloud DNS to point to machines running caddy service...
|
||||
[+] Verifying internet access to caddy service 2/2
|
||||
✔ Machine hetzner-server (5.223.45.199) Reachable 0.2s
|
||||
✔ Machine oracle-vm (152.67.101.197) Reachable 0.1s
|
||||
|
||||
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||
*.xuw3xd.cluster.uncloud.run A → 152.67.101.197, 5.223.45.199
|
||||
|
||||
$ uc machine ls
|
||||
NAME STATE ADDRESS PUBLIC IP WIREGUARD ENDPOINTS
|
||||
oracle-vm Up 10.210.0.1/24 152.67.101.197 10.0.0.95:51820, 152.67.101.197:51820
|
||||
hetzner-server Up 10.210.1.1/24 5.223.45.199 5.223.45.199:51820, [2a01:4ff:2f0:128b::1]:51820
|
||||
```
|
||||
|
||||
1. The second machine gets provisioned just like the first. A non-root SSH user will need `sudo` access.
|
||||
2. Allocates a new subnet `10.210.1.0/24` for the second machine and its containers.
|
||||
3. Registers the second machine in the cluster state and exchanges WireGuard keys with the first machine.
|
||||
4. Both machines establish a WireGuard tunnel between each other, allowing Docker containers connected to the bridge
|
||||
network to communicate directly across machines.
|
||||
5. Configures and starts `corrosion` on the second machine to sync the cluster state.
|
||||
6. The second machine is added as an alternative entry point in the cluster config.
|
||||
7. If one of the machines goes offline, the other machine can still serve cluster operations.
|
||||
|
||||
If one more machine is added, the process repeats with a new subnet. The new machine needs to establish a WireGuard
|
||||
connection with only one of the existing machines. Other machines will learn about it through the shared cluster state
|
||||
and automatically establish a WireGuard tunnel with it.
|
||||
|
||||
**When you run a service:**
|
||||
|
||||
```bash
|
||||
$ uc run -p app.example.com:8000/https image/my-app
|
||||
|
||||
[+] Running service my-app-1b3b (replicated mode) 1/1
|
||||
✔ Container my-app-1b3b-tcex on oracle-vm Started
|
||||
|
||||
my-app-1b3b endpoints:
|
||||
• https://app.example.com → :8000
|
||||
• https://my-app-1b3b.xuw3xd.cluster.uncloud.run → :8000
|
||||
```
|
||||
|
||||
1. CLI picks a machine to run your container.
|
||||
2. `uncloudd` that the CLI communicates with uses [`grpc-proxy`](https://github.com/siderolabs/grpc-proxy) to forward
|
||||
the request to the target machine to launch a container there.
|
||||
3. `uncloudd` on the target machine starts the Docker container in the bridge network and stores its info in the
|
||||
cluster's distributed state.
|
||||
4. The container gets a cluster-unique IP address from the bridge network (in the `10.210.X.2-254` range) and becomes
|
||||
accessible from other machines in the cluster.
|
||||
5. Caddy reverse proxy which runs in [`global`](https://github.com/compose-spec/compose-spec/blob/main/deploy.md#mode)
|
||||
mode on each machine watches the cluster state for new services and updates its configuration to route traffic to the
|
||||
new container.
|
||||
|
||||
Look ma, no control plane or master nodes to maintain! Just a simple overlay network and eventually consistent state
|
||||
sync that lets machines work together. Want to check on things or make changes? Connect to any machine either implicitly
|
||||
using the CLI or directly over SSH. They all have the complete cluster state and can control everything. It's like each
|
||||
machine is a full backup of your control plane.
|
||||
</details>
|
||||
|
||||
## 🏗 Project status
|
||||
|
||||
Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
|
||||
and there may be breaking changes between releases.
|
||||
|
||||
We'd love your input! Here's how you can contribute:
|
||||
|
||||
* 🐛 Found a bug? [Open an issue](https://github.com/psviderski/uncloud/issues)
|
||||
* 💡 Have questions, ideas, or need help?
|
||||
* Start a discussion or join an existing one in
|
||||
the [Discussions](https://github.com/psviderski/uncloud/discussions).
|
||||
* Join our [Discord community](https://discord.gg/eR35KQJhPu) where we discuss features, roadmap, implementation
|
||||
details, and help each other out.
|
||||
|
||||
## 🙏 Inspiration & Acknowledgements
|
||||
|
||||
I'm grateful to the following projects that inspired Uncloud's design and implementation:
|
||||
|
||||
* [Kamal](https://kamal-deploy.org/) — for proving that even in the declarative era of Kubernetes there is a place for
|
||||
simple deployment tools that use imperative commands without complex orchestration. Kamal powers the multi-billion
|
||||
dollar company [37signals](https://37signals.com/) where it was created, and that's truly inspiring!
|
||||
* [Fly.io](https://fly.io/) — for inspiring my vision for what self-hosted infrastructure should feel like, proving that
|
||||
developer experience and powerful infrastructure can coexist beautifully.
|
||||
* [Tailscale](https://tailscale.com/) — for pioneering the vision of decentralised flat mesh networking with an amazing
|
||||
user experience that feels like magic.
|
||||
* [Talos Linux](https://github.com/siderolabs/talos)
|
||||
and [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/) — for the machine API design using
|
||||
[grpc-proxy](https://github.com/siderolabs/grpc-proxy) and for its elegant approach to secure WireGuard-based overlay
|
||||
networking with zero configuration.
|
||||
* [Docker Swarm Classic](https://github.com/docker-archive/classicswarm) and
|
||||
[Rancher 1.x](http://rancher-com-website-main-elb-elb-1798790864.us-west-2.elb.amazonaws.com/docs/rancher/v1.6/en/)
|
||||
— for showing the power of simplicity and pragmatism in container orchestration and that not every problem needs the
|
||||
complexity of Kubernetes.
|
||||
|
||||
Special thanks to the [Corrosion](https://github.com/superfly/corrosion) project by Fly.io for providing the distributed
|
||||
SQLite database used to share Uncloud's cluster state.
|
||||
|
||||
## 📫 Stay updated
|
||||
|
||||
* Join our [Discord server](https://discord.gg/eR35KQJhPu) for real-time discussions, support, and updates.
|
||||
* Follow [@psviderski](https://x.com/psviderski) on X/Twitter.
|
||||
* Subscribe to [my newsletter](https://uncloud.run/#subscribe) to follow the progress, get early insights into new
|
||||
features, and be the first to know when it's ready for production use.
|
||||
* Watch this repository for releases.
|
||||
|
||||
## ❤️ Contributors
|
||||
|
||||
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
|
||||
|
||||
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=psviderski/uncloud" />
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
)
|
||||
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
opts := ucind.CreateClusterOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "create [NAME]",
|
||||
Short: "Create a new cluster.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := cmd.Context().Value("provisioner").(*ucind.Provisioner)
|
||||
|
||||
name := DefaultClusterName
|
||||
if len(args) > 0 {
|
||||
name = args[0]
|
||||
}
|
||||
|
||||
if _, err := p.CreateCluster(cmd.Context(), name, opts); err != nil {
|
||||
return fmt.Errorf("create cluster '%s': %w", name, err)
|
||||
}
|
||||
fmt.Printf("Cluster '%s' created.\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVarP(&opts.Machines, "machines", "m", 1, "Number of machines to create.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
)
|
||||
|
||||
func NewRemoveCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm [NAME]",
|
||||
Short: "Remove a cluster.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := cmd.Context().Value("provisioner").(*ucind.Provisioner)
|
||||
|
||||
name := DefaultClusterName
|
||||
if len(args) > 0 {
|
||||
name = args[0]
|
||||
}
|
||||
|
||||
if err := p.RemoveCluster(cmd.Context(), name); err != nil {
|
||||
return fmt.Errorf("remove cluster '%s': %w", name, err)
|
||||
}
|
||||
fmt.Printf("Cluster '%s' removed.\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const DefaultClusterName = "ucind-default"
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cluster",
|
||||
Short: "Manage local Docker-based clusters.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewCreateCommand(),
|
||||
//NewListCommand(),
|
||||
NewRemoveCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/psviderski/uncloud/cmd/ucind/cluster"
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath string
|
||||
cmd := &cobra.Command{
|
||||
Use: "ucind",
|
||||
Short: "A CLI tool for running Uncloud test clusters using Docker.",
|
||||
Long: "A CLI tool for running Uncloud test clusters using Docker.\n" +
|
||||
"Machines in a ucind cluster are Docker containers running a ucind image. All machines within a cluster " +
|
||||
"are connected to the same Docker network.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create Docker client: %w", err)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(configPath, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get user home directory to resolve %q: %w", configPath, err)
|
||||
}
|
||||
configPath = strings.Replace(configPath, "~", home, 1)
|
||||
}
|
||||
configUpdater := ucind.NewConfigUpdater(configPath)
|
||||
|
||||
p := ucind.NewProvisioner(cli, configUpdater)
|
||||
// Persist the provisioner in the context so it can be used by subcommands.
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), "provisioner", p))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||
"path to the Uncloud configuration file.")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||
|
||||
cmd.AddCommand(
|
||||
cluster.NewRootCommand(),
|
||||
)
|
||||
cobra.CheckErr(cmd.Execute())
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewBuildCommand creates a new command to build services from a Compose file.
|
||||
func NewBuildCommand() *cobra.Command {
|
||||
opts := cli.BuildOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "build [FLAGS] [SERVICE...]",
|
||||
Short: "Build services from a Compose file.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
if len(args) > 0 {
|
||||
opts.Services = args
|
||||
}
|
||||
|
||||
return runBuild(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.Files, "file", "f", nil,
|
||||
"One or more Compose files to build (default compose.yaml)")
|
||||
cmd.Flags().StringSliceVarP(&opts.Profiles, "profile", "p", nil,
|
||||
"One or more Compose profiles to enable.")
|
||||
cmd.Flags().BoolVarP(&opts.Push, "push", "P", false,
|
||||
"Push built images to the registry after building. (default false)")
|
||||
cmd.Flags().BoolVarP(&opts.NoCache, "no-cache", "n", false,
|
||||
"Do not use cache when building images. (default false)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// TODO: deduplicate with a similar functino for deploy options
|
||||
// projectOpts returns the project options for the Compose file(s).
|
||||
func projectOptsFromBuildOpts(opts cli.BuildOptions) []composecli.ProjectOptionsFn {
|
||||
projectOpts := []composecli.ProjectOptionsFn{}
|
||||
|
||||
if len(opts.Profiles) > 0 {
|
||||
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.Profiles...))
|
||||
}
|
||||
|
||||
return projectOpts
|
||||
}
|
||||
|
||||
// runBuild parses the Compose file(s), builds the services, and pushes them if requested.
|
||||
func runBuild(ctx context.Context, uncli *cli.CLI, opts cli.BuildOptions) error {
|
||||
projectOpts := projectOptsFromBuildOpts(opts)
|
||||
project, err := compose.LoadProject(ctx, opts.Files, projectOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load compose file(s): %w", err)
|
||||
}
|
||||
|
||||
if len(opts.Services) > 0 {
|
||||
project, err = project.WithSelectedServices(opts.Services)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select services: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
||||
|
||||
if len(servicesToBuild) == 0 {
|
||||
fmt.Println("No services to build.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return cli.BuildServices(ctx, servicesToBuild, opts)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type deployOptions struct {
|
||||
image string
|
||||
machines []string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewDeployCommand() *cobra.Command {
|
||||
opts := deployOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "deploy",
|
||||
Short: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.",
|
||||
Long: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.\n" +
|
||||
"A rolling update is performed when updating existing containers to minimise disruption.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return runDeploy(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.image, "image", "",
|
||||
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Machine names to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||
"list of machine names. (default is all machines)")
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context to deploy to. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||
if err != nil {
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
return fmt.Errorf("inspect caddy service: %w", err)
|
||||
}
|
||||
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName)
|
||||
} else {
|
||||
fmt.Printf("Service: %s (%s mode)\n", svc.Name, svc.Mode)
|
||||
|
||||
// Collect unique images of all containers in the running caddy service.
|
||||
images := make(map[string]struct{}, len(svc.Containers))
|
||||
for _, c := range svc.Containers {
|
||||
images[c.Container.Config.Image] = struct{}{}
|
||||
}
|
||||
currentImages := slices.Collect(maps.Keys(images))
|
||||
|
||||
if len(currentImages) > 1 {
|
||||
commaSeparatedImages := strings.Join(currentImages, ", ")
|
||||
fmt.Printf("Current images (multiple versions detected): %s\n", commaSeparatedImages)
|
||||
} else {
|
||||
fmt.Printf("Current image: %s\n", currentImages[0])
|
||||
}
|
||||
}
|
||||
|
||||
if opts.image != "" {
|
||||
fmt.Printf("Target image: %s\n", opts.image)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Preparing a deployment plan...")
|
||||
|
||||
placement := api.Placement{
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||
}
|
||||
d, err := clusterClient.NewCaddyDeployment(opts.image, placement)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
if opts.image == "" {
|
||||
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image)
|
||||
}
|
||||
|
||||
plan, err := d.Plan(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
if len(plan.Operations) == 0 {
|
||||
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
|
||||
} else {
|
||||
if svc.ID == "" {
|
||||
if len(opts.machines) > 0 {
|
||||
fmt.Println("This will run a Caddy container on each selected machine.")
|
||||
} else {
|
||||
fmt.Println("This will run a Caddy container on each machine.")
|
||||
}
|
||||
}
|
||||
|
||||
// Initialise a machine and container name resolver to properly format the plan output.
|
||||
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Deployment plan:")
|
||||
fmt.Println(plan.Format(resolver))
|
||||
fmt.Println()
|
||||
|
||||
confirmed, err := cli.Confirm()
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm deployment: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Println("Cancelled. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if _, err = d.Run(ctx); err != nil {
|
||||
return fmt.Errorf("deploy caddy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
|
||||
}
|
||||
|
||||
func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
|
||||
domain, err := clusterClient.GetDomain(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNotFound) {
|
||||
fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("get cluster domain: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Updating cluster domain records in Uncloud DNS to point to machines running caddy service...")
|
||||
// TODO: split the method into two: one to get the records and one to update them to ask for update confirmation.
|
||||
|
||||
var records []*pb.DNSRecord
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName)
|
||||
return err
|
||||
}, progressOut, "Verifying internet access to caddy service")
|
||||
if err != nil {
|
||||
if errors.Is(err, client.ErrNoReachableMachines) {
|
||||
fmt.Println()
|
||||
fmt.Printf("DNS records for domain '%s' could not be updated as there are no internet-reachable "+
|
||||
"machines running caddy containers.\n", domain)
|
||||
fmt.Println()
|
||||
fmt.Println("Possible solutions:")
|
||||
fmt.Println("- Ensure your machines have public IP addresses")
|
||||
fmt.Println("- Use --public-ip flag when adding machines to override the automatically detected IPs")
|
||||
fmt.Println("- Check firewall settings on your machines")
|
||||
fmt.Println("- Configure port forwarding if behind NAT")
|
||||
fmt.Println("- Retry Caddy deployment with 'uc caddy deploy' after resolving connectivity issues")
|
||||
fmt.Println()
|
||||
fmt.Println("Your services won't be accessible from the internet until at least one machine " +
|
||||
"becomes reachable. If you aren't planning to expose any services publicly, you can release " +
|
||||
"the domain by running 'uc dns release'.")
|
||||
}
|
||||
return fmt.Errorf("failed to update DNS records pointing to caddy service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("DNS records updated to use only the internet-reachable machines running caddy service:")
|
||||
for _, r := range records {
|
||||
fmt.Printf(" %s %s → %s\n", r.Name, r.Type, strings.Join(r.Values, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "caddy",
|
||||
Short: "Manage Caddy reverse proxy service.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewDeployCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
var clusterContext string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List available cluster contexts.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(uncli)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&clusterContext, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func list(uncli *cli.CLI) error {
|
||||
if uncli.Config == nil {
|
||||
return fmt.Errorf("context management is not available: Uncloud configuration file is not being used")
|
||||
}
|
||||
|
||||
if len(uncli.Config.Contexts) == 0 {
|
||||
fmt.Println("No contexts found")
|
||||
return nil
|
||||
}
|
||||
|
||||
contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
|
||||
currentContext := uncli.Config.CurrentContext
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
fmt.Fprintln(tw, "NAME\tCURRENT\tCONNECTIONS")
|
||||
|
||||
for _, name := range contextNames {
|
||||
current := ""
|
||||
if name == currentContext {
|
||||
current = "*"
|
||||
}
|
||||
connCount := len(uncli.Config.Contexts[name].Connections)
|
||||
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
||||
}
|
||||
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ctx",
|
||||
Aliases: []string{"context"},
|
||||
Short: "Switch between different cluster contexts. Contains subcommands to manage contexts.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return selectContext(uncli)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
NewListCommand(),
|
||||
NewUseCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewUseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use [CONTEXT]",
|
||||
Short: "Switch to a different cluster context.",
|
||||
Long: "Switch to a different cluster context. If no context is provided, " +
|
||||
"a list of available contexts will be displayed for selection.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
if len(args) == 1 {
|
||||
if err := uncli.SetCurrentContext(args[0]); err != nil {
|
||||
return fmt.Errorf("failed to set the current cluster context to '%s': %w", args[0], err)
|
||||
}
|
||||
fmt.Printf("Current cluster context is now '%s'.\n", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
return selectContext(uncli)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func selectContext(uncli *cli.CLI) error {
|
||||
if uncli.Config == nil {
|
||||
return fmt.Errorf("context management is not available: Uncloud configuration file is not being used")
|
||||
}
|
||||
if len(uncli.Config.Contexts) == 0 {
|
||||
return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path())
|
||||
}
|
||||
|
||||
contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
|
||||
|
||||
var selected string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select a cluster context").
|
||||
Options(buildContextOptions(contextNames, uncli.Config.CurrentContext)...).
|
||||
Value(&selected),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("select cluster context: %w", err)
|
||||
}
|
||||
|
||||
if err := uncli.SetCurrentContext(selected); err != nil {
|
||||
return fmt.Errorf("set current cluster context: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Current cluster context is now '%s'.\n", selected)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildContextOptions(contexts []string, current string) []huh.Option[string] {
|
||||
options := make([]huh.Option[string], len(contexts))
|
||||
|
||||
for i, ctx := range contexts {
|
||||
opt := huh.NewOption(ctx, ctx)
|
||||
if ctx == current {
|
||||
opt.Key += " (current)"
|
||||
opt = opt.Selected(true)
|
||||
}
|
||||
|
||||
options[i] = opt
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type deployOptions struct {
|
||||
files []string
|
||||
profiles []string
|
||||
services []string
|
||||
noBuild bool
|
||||
|
||||
context string
|
||||
}
|
||||
|
||||
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
||||
func NewDeployCommand() *cobra.Command {
|
||||
opts := deployOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "deploy [FLAGS] [SERVICE...]",
|
||||
Short: "Deploy services from a Compose file.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
if len(args) > 0 {
|
||||
opts.services = args
|
||||
}
|
||||
|
||||
return runDeploy(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||
"One or more Compose files to deploy services from. (default compose.yaml)")
|
||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||
"One or more Compose profiles to enable.")
|
||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context to deploy to (default is the current context)")
|
||||
cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false,
|
||||
"Do not build images before deploying services. (default false)")
|
||||
|
||||
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
||||
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// projectOpts returns the project options for the Compose file(s).
|
||||
func projectOpts(opts deployOptions) []composecli.ProjectOptionsFn {
|
||||
projectOpts := []composecli.ProjectOptionsFn{}
|
||||
|
||||
if len(opts.profiles) > 0 {
|
||||
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.profiles...))
|
||||
}
|
||||
|
||||
return projectOpts
|
||||
}
|
||||
|
||||
// runDeploy parses the Compose file(s) and deploys the services.
|
||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
projectOpts := projectOpts(opts)
|
||||
|
||||
project, err := compose.LoadProject(ctx, opts.files, projectOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load compose file(s): %w", err)
|
||||
}
|
||||
|
||||
if len(opts.services) > 0 {
|
||||
// Includes service dependencies by default. This is the default docker compose behavior.
|
||||
project, err = project.WithSelectedServices(opts.services)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select services: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
||||
|
||||
if len(servicesToBuild) > 0 {
|
||||
if opts.noBuild {
|
||||
fmt.Println("Not building services as requested.")
|
||||
} else {
|
||||
buildOpts := cli.BuildOptions{
|
||||
Push: true,
|
||||
NoCache: false,
|
||||
}
|
||||
|
||||
if err := cli.BuildServices(ctx, servicesToBuild, buildOpts); err != nil {
|
||||
return fmt.Errorf("build services: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
composeDeploy, err := compose.NewDeployment(ctx, clusterClient, project)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create compose deployment: %w", err)
|
||||
}
|
||||
|
||||
plan, err := composeDeploy.Plan(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan deployment: %w", err)
|
||||
}
|
||||
|
||||
if len(plan.Operations) == 0 {
|
||||
fmt.Println("Services are up to date.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Deployment plan:")
|
||||
if err = printPlan(ctx, clusterClient, plan); err != nil {
|
||||
return fmt.Errorf("print deployment plan: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
confirmed, err := cli.Confirm()
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm deployment: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Println("Cancelled. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err := plan.Execute(ctx, clusterClient); err != nil {
|
||||
return fmt.Errorf("deploy services: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), "Deploying services")
|
||||
}
|
||||
|
||||
func printPlan(ctx context.Context, cli *client.Client, plan deploy.SequenceOperation) error {
|
||||
for _, op := range plan.Operations {
|
||||
svcPlan, ok := op.(*deploy.Plan)
|
||||
if !ok {
|
||||
fmt.Println("- " + op.Format(nil))
|
||||
continue
|
||||
}
|
||||
|
||||
svc, err := cli.InspectService(ctx, svcPlan.ServiceID)
|
||||
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||
return fmt.Errorf("inspect service: %w", err)
|
||||
}
|
||||
// Initialise a machine and container name resolver to properly format the service plan output.
|
||||
resolver, err := cli.ServiceOperationNameResolver(ctx, svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("- Deploy service [name=%s]\n", svcPlan.ServiceName)
|
||||
fmt.Println(indent(svcPlan.Format(resolver), " "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func indent(text, prefix string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = prefix + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
type releaseOptions struct {
|
||||
context string
|
||||
}
|
||||
|
||||
func NewReleaseCommand() *cobra.Command {
|
||||
opts := releaseOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "release",
|
||||
Short: "Release the reserved cluster domain.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return release(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func release(ctx context.Context, uncli *cli.CLI, opts releaseOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
domain, err := client.ReleaseDomain(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return errors.New("no domain reserved")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Released cluster domain: %s\n", domain.Name)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
|
||||
|
||||
type reserveOptions struct {
|
||||
endpoint string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewReserveCommand() *cobra.Command {
|
||||
opts := reserveOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "reserve",
|
||||
Short: "Reserve a cluster domain in Uncloud DNS.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return reserve(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
|
||||
"API endpoint for the Uncloud DNS service.")
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
domain, err := clusterClient.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.endpoint})
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.AlreadyExists {
|
||||
return errors.New("domain already reserved")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
||||
|
||||
// Update cluster domain records in Uncloud DNS to point to machines running caddy service if it has been deployed.
|
||||
if _, err = clusterClient.InspectService(ctx, client.CaddyServiceName); err != nil {
|
||||
if errors.Is(err, api.ErrNotFound) {
|
||||
fmt.Println("Deploy the Caddy reverse proxy service ('uc caddy deploy') to enable internet access " +
|
||||
"to your services via the reserved or your custom domain.")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inspect caddy service: %w", err)
|
||||
}
|
||||
|
||||
return caddy.UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dns",
|
||||
Short: "Manage cluster domain in Uncloud DNS.",
|
||||
Long: "Manage cluster domain in Uncloud DNS.\n" +
|
||||
"DNS commands allow you to reserve or release a unique '<id>.cluster.uncloud.run' domain for your " +
|
||||
"cluster. When reserved, Caddy service deployments will automatically update DNS records to route " +
|
||||
"traffic to the services in the cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewReleaseCommand(),
|
||||
NewReserveCommand(),
|
||||
NewShowCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type showOptions struct {
|
||||
context string
|
||||
}
|
||||
|
||||
func NewShowCommand() *cobra.Command {
|
||||
opts := showOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Print the cluster domain name.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return show(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
domain, err := clusterClient.GetDomain(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNotFound) {
|
||||
return errors.New("no domain reserved")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(domain)
|
||||
return nil
|
||||
}
|
||||
+148
-10
@@ -1,16 +1,31 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/cli/config"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type addOptions struct {
|
||||
name string
|
||||
noCaddy bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
cluster string
|
||||
context string
|
||||
version string
|
||||
}
|
||||
|
||||
func NewAddCommand() *cobra.Command {
|
||||
@@ -33,17 +48,140 @@ func NewAddCommand() *cobra.Command {
|
||||
KeyPath: opts.sshKey,
|
||||
}
|
||||
|
||||
return uncli.AddMachine(cmd.Context(), remoteMachine, opts.cluster, opts.name)
|
||||
return add(cmd.Context(), uncli, remoteMachine, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Assign a name to the machine")
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.sshKey, "ssh-key", "i", "",
|
||||
"path to SSH private key for SSH remote login (default ~/.ssh/id_*)",
|
||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Assign a name to the machine.")
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noCaddy, "no-caddy", false,
|
||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "auto",
|
||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||
"blank '' or 'none' to disable ingress on this machine, or specify an IP address.",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "cluster", "c", "",
|
||||
"Name of the cluster to add the machine to (default is the current cluster)",
|
||||
&opts.sshKey, "ssh-key", "i", "~/.ssh/id_ed25519",
|
||||
"Path to SSH private key for remote login (if not already added to SSH agent).",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.version, "version", "latest",
|
||||
"Version of the Uncloud daemon to install on the machine.",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context to add the machine to. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, opts addOptions) error {
|
||||
var publicIP *netip.Addr
|
||||
switch opts.publicIP {
|
||||
case "auto":
|
||||
publicIP = &netip.Addr{}
|
||||
case "", "none":
|
||||
publicIP = nil
|
||||
default:
|
||||
ip, err := netip.ParseAddr(opts.publicIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse public IP: %w", err)
|
||||
}
|
||||
publicIP = &ip
|
||||
}
|
||||
|
||||
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
||||
Context: opts.context,
|
||||
MachineName: opts.name,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
Version: opts.version,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
defer machineClient.Close()
|
||||
|
||||
if opts.noCaddy {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait for the cluster to be initialised to be able to deploy the Caddy service.
|
||||
fmt.Println("Waiting for the machine to be ready...")
|
||||
fmt.Println()
|
||||
if err = waitClusterInitialised(ctx, machineClient); err != nil {
|
||||
return fmt.Errorf("wait for cluster to be initialised on machine: %w", err)
|
||||
}
|
||||
|
||||
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
||||
// use the deployed image version. Otherwise, use the latest version.
|
||||
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
|
||||
// issues accessing the Machine API of existing machines in the cluster.
|
||||
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
|
||||
caddyImage := ""
|
||||
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||
if err != nil {
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
return fmt.Errorf("inspect caddy service: %w", err)
|
||||
}
|
||||
} else {
|
||||
caddyImage = caddySvc.Containers[0].Container.Config.Image
|
||||
// Find the latest created container and use its image.
|
||||
var latestCreated time.Time
|
||||
for _, c := range caddySvc.Containers[1:] {
|
||||
created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if created.After(latestCreated) {
|
||||
latestCreated = created
|
||||
caddyImage = c.Container.Config.Image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||
// that may cause a small downtime.
|
||||
d, err := clusterClient.NewCaddyDeployment(caddyImage, api.Placement{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if _, err = d.Run(ctx); err != nil {
|
||||
return fmt.Errorf("deploy caddy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
||||
}
|
||||
|
||||
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
|
||||
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||
backoff.WithMaxInterval(1*time.Second),
|
||||
backoff.WithMaxElapsedTime(5*time.Minute),
|
||||
), ctx)
|
||||
|
||||
check := func() error {
|
||||
_, err := client.ListMachines(ctx, nil)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
statusErr := status.Convert(err)
|
||||
if statusErr.Code() == codes.FailedPrecondition {
|
||||
return err
|
||||
}
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
|
||||
return backoff.Retry(check, boff)
|
||||
}
|
||||
|
||||
+126
-27
@@ -1,30 +1,41 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"net/netip"
|
||||
"uncloud/internal/cli"
|
||||
"uncloud/internal/cli/config"
|
||||
"uncloud/internal/machine/network"
|
||||
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type initOptions struct {
|
||||
dnsEndpoint string
|
||||
name string
|
||||
network string
|
||||
userPublicKey string
|
||||
|
||||
noCaddy bool
|
||||
noDNS bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
cluster string
|
||||
version string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewInitCommand() *cobra.Command {
|
||||
opts := initOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "init [USER@HOST:PORT]",
|
||||
Short: "Initialise a new cluster with a remote machine as the first member.",
|
||||
// TODO: include usage examples of initialising a remote machine.
|
||||
// TODO: support initialising a cluster on the local machine.
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
// TODO: include usage examples of initialising a local and remote machine.
|
||||
Short: "Initialise a new cluster that consists of the local or remote machine.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
@@ -41,30 +52,118 @@ func NewInitCommand() *cobra.Command {
|
||||
KeyPath: opts.sshKey,
|
||||
}
|
||||
}
|
||||
|
||||
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.dnsEndpoint, "dns-endpoint", dns.DefaultUncloudDNSAPIEndpoint,
|
||||
"API endpoint for the Uncloud DNS service.")
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.name, "name", "n", "",
|
||||
"Assign a name to the machine.",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.network, "network", cluster.DefaultNetwork.String(),
|
||||
"IPv4 network CIDR to use for machines and services.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noCaddy, "no-caddy", false,
|
||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noDNS, "no-dns", false,
|
||||
"Don't reserve a cluster domain in Uncloud DNS.",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "auto",
|
||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||
"blank '' or 'none' to disable ingress on this machine, or specify an IP address.",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.sshKey, "ssh-key", "i", "~/.ssh/id_ed25519",
|
||||
"Path to SSH private key for remote login (if not already added to SSH agent).",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.version, "version", "latest",
|
||||
"Version of the Uncloud daemon to install on the machine.",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "default",
|
||||
"Name of the created context for the initialised cluster in the Uncloud config.",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
|
||||
netPrefix, err := netip.ParsePrefix(opts.network)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse network CIDR: %w", err)
|
||||
}
|
||||
|
||||
return uncli.InitCluster(cmd.Context(), remoteMachine, opts.cluster, opts.name, netPrefix)
|
||||
},
|
||||
var publicIP *netip.Addr
|
||||
switch opts.publicIP {
|
||||
case "auto":
|
||||
publicIP = &netip.Addr{}
|
||||
case "", "none":
|
||||
publicIP = nil
|
||||
default:
|
||||
ip, err := netip.ParseAddr(opts.publicIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse public IP: %w", err)
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Assign a name to the machine")
|
||||
cmd.Flags().StringVar(
|
||||
&opts.network, "network", network.DefaultNetwork.String(),
|
||||
"IPv4 network CIDR to use for machines and services",
|
||||
)
|
||||
//cmd.Flags().StringVar(&opts.userPublicKey, "user-pubkey", "",
|
||||
// "User's public key which will be able to access the cluster (hex-encoded)")
|
||||
publicIP = &ip
|
||||
}
|
||||
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{
|
||||
Context: opts.context,
|
||||
MachineName: opts.name,
|
||||
Network: netPrefix,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
Version: opts.version,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.sshKey, "ssh-key", "i", "",
|
||||
"path to SSH private key for SSH remote login (default ~/.ssh/id_*)",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "cluster", "c", "",
|
||||
"Name of the cluster in the local config if initialising a remote machine",
|
||||
)
|
||||
if opts.noCaddy && opts.noDNS {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cmd
|
||||
// Deploy the Caddy service to the initialised machine.
|
||||
// The creation of a deployment plan talks to cluster API. Since the API needs a few moments to become available
|
||||
// after cluster initialisation, we keep the user informed during this wait.
|
||||
fmt.Println("Waiting for the machine to be ready...")
|
||||
fmt.Println()
|
||||
|
||||
if !opts.noDNS {
|
||||
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err)
|
||||
}
|
||||
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
||||
}
|
||||
|
||||
if !opts.noCaddy {
|
||||
d, err := client.NewCaddyDeployment("", api.Placement{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if _, err = d.Run(ctx); err != nil {
|
||||
return fmt.Errorf("deploy caddy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return caddy.UpdateDomainRecords(ctx, client, uncli.ProgressOut())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/cli"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
opts := addOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List machines in a cluster",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return uncli.ListMachines(cmd.Context(), opts.cluster)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "cluster", "c", "",
|
||||
"Name of the cluster (default is the current cluster)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
var contextName string
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List machines in a cluster.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(cmd.Context(), uncli, contextName)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&contextName, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
client, err := uncli.ConnectCluster(ctx, clusterName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
machines, err := client.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
|
||||
// Print the list of machines in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
// Print header.
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
// Print rows.
|
||||
for _, member := range machines {
|
||||
m := member.Machine
|
||||
subnet, _ := m.Network.Subnet.ToPrefix()
|
||||
subnet = netip.PrefixFrom(network.MachineIP(subnet), subnet.Bits())
|
||||
|
||||
publicIP := "-"
|
||||
if m.PublicIp != nil {
|
||||
ip, _ := m.PublicIp.ToAddr()
|
||||
publicIP = ip.String()
|
||||
}
|
||||
|
||||
endpoints := make([]string, len(m.Network.Endpoints))
|
||||
for i, ep := range m.Network.Endpoints {
|
||||
addrPort, _ := ep.ToAddrPort()
|
||||
endpoints[i] = addrPort.String()
|
||||
}
|
||||
|
||||
if _, err = fmt.Fprintf(
|
||||
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||
strings.Join(endpoints, ", "),
|
||||
); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// capitalise returns a string where the first character is upper case, and the rest is lower case.
|
||||
func capitalise(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/tree"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type removeOptions struct {
|
||||
force bool
|
||||
yes bool
|
||||
context string
|
||||
}
|
||||
|
||||
func NewRmCommand() *cobra.Command {
|
||||
opts := removeOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm MACHINE",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Remove a machine from a cluster.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return remove(cmd.Context(), uncli, args[0], opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)")
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Do not prompt for confirmation before removing the machine.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, uncli *cli.CLI, machineName string, opts removeOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Verify the machine exists and list all service containers on it including stopped ones.
|
||||
listCtx, machines, err := api.ProxyMachinesContext(ctx, client, []string{machineName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(machines) == 0 {
|
||||
return fmt.Errorf("machine '%s' not found in the cluster", machineName)
|
||||
}
|
||||
m := machines[0].Machine
|
||||
|
||||
listOpts := container.ListOptions{All: true}
|
||||
machineContainers, err := client.Docker.ListServiceContainers(listCtx, "", listOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
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 on the machine, reset it to the uninitialised state, " +
|
||||
"and remove it from the cluster.")
|
||||
} else {
|
||||
fmt.Printf("No service containers found on machine '%s'.\n", m.Name)
|
||||
fmt.Println("This will reset the machine to the uninitialised state and remove it from the cluster.")
|
||||
}
|
||||
|
||||
if !opts.yes {
|
||||
confirmed, err := cli.Confirm()
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm removal: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Println("Cancelled. Machine was not removed.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if 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()
|
||||
}
|
||||
|
||||
// TODO: 4. Implement and call Reset via Machine API to reset the machine state to uninitialised.
|
||||
// TODO: 5. Remove the machine from the cluster store.
|
||||
|
||||
return fmt.Errorf("resetting machine is not fully implemented yet")
|
||||
//fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
|
||||
//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
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "machine",
|
||||
Aliases: []string{"m"},
|
||||
Short: "Manage machines in an Uncloud cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewAddCommand(),
|
||||
NewInitCommand(),
|
||||
NewListCommand(),
|
||||
NewRmCommand(),
|
||||
NewTokenCommand(),
|
||||
)
|
||||
return cmd
|
||||
|
||||
@@ -3,8 +3,8 @@ package machine
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"uncloud/internal/daemon"
|
||||
"uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/daemon"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
)
|
||||
|
||||
type tokenOptions struct {
|
||||
@@ -31,7 +31,7 @@ func NewTokenCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.dataDir, "data-dir", "d", machine.DefaultDataDir,
|
||||
"Directory for storing persistent machine state")
|
||||
"Directory for storing persistent machine state.")
|
||||
_ = cmd.MarkFlagDirname("data-dir")
|
||||
|
||||
return cmd
|
||||
|
||||
+60
-14
@@ -3,44 +3,90 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"uncloud/cmd/uncloud/machine"
|
||||
"uncloud/internal/cli"
|
||||
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type globalOptions struct {
|
||||
configPath string
|
||||
connect string
|
||||
}
|
||||
|
||||
func main() {
|
||||
var configPath string
|
||||
opts := globalOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "uncloud",
|
||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||
Version: version.String(),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.HasPrefix(configPath, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
var conn *config.MachineConnection
|
||||
if opts.connect != "" {
|
||||
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||
addrPort, err := netip.ParseAddrPort(opts.connect[len("tcp://"):])
|
||||
if err != nil {
|
||||
return fmt.Errorf("get user home directory to resolve %q: %w", configPath, err)
|
||||
return fmt.Errorf("parse TCP address: %w", err)
|
||||
}
|
||||
conn = &config.MachineConnection{
|
||||
TCP: &addrPort,
|
||||
}
|
||||
} else {
|
||||
dest := opts.connect
|
||||
if strings.HasPrefix(dest, "ssh://") {
|
||||
dest = dest[len("ssh://"):]
|
||||
}
|
||||
conn = &config.MachineConnection{
|
||||
SSH: config.SSHDestination(dest),
|
||||
}
|
||||
}
|
||||
configPath = strings.Replace(configPath, "~", home, 1)
|
||||
}
|
||||
|
||||
uncli, err := cli.New(configPath)
|
||||
configPath := fs.ExpandHomeDir(opts.configPath)
|
||||
uncli, err := cli.New(configPath, conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize CLI: %w", err)
|
||||
return fmt.Errorf("initialise CLI: %w", err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), "cli", uncli))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||
"Connect to a remote cluster machine without using the Uncloud configuration file.\n"+
|
||||
"Format: [ssh://]user@host[:port] or tcp://host:port")
|
||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.toml",
|
||||
"path to the Uncloud configuration file")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "toml")
|
||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||
"Path to the Uncloud configuration file.")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||
// TODO: make --context a global flag and pass it as a value of the command context.
|
||||
|
||||
cmd.AddCommand(
|
||||
NewDeployCommand(),
|
||||
NewBuildCommand(),
|
||||
caddy.NewRootCommand(),
|
||||
cmdcontext.NewRootCommand(),
|
||||
dns.NewRootCommand(),
|
||||
machine.NewRootCommand(),
|
||||
service.NewRootCommand(),
|
||||
service.NewInspectCommand(),
|
||||
service.NewListCommand(),
|
||||
service.NewRmCommand(),
|
||||
service.NewRunCommand(),
|
||||
service.NewScaleCommand(),
|
||||
volume.NewRootCommand(),
|
||||
)
|
||||
cobra.CheckErr(cmd.Execute())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/docker/go-units"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type inspectOptions struct {
|
||||
service string
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewInspectCommand() *cobra.Command {
|
||||
opts := inspectOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "inspect SERVICE",
|
||||
Short: "Display detailed information on a service.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.service = args[0]
|
||||
return inspect(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
svc, err := client.InspectService(ctx, opts.service)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect service: %w", err)
|
||||
}
|
||||
|
||||
machines, err := client.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
machinesNamesByID := make(map[string]string)
|
||||
for _, m := range machines {
|
||||
machinesNamesByID[m.Machine.Id] = m.Machine.Name
|
||||
}
|
||||
|
||||
fmt.Printf("ID: %s\n", svc.ID)
|
||||
fmt.Printf("Name: %s\n", svc.Name)
|
||||
fmt.Printf("Mode: %s\n", svc.Mode)
|
||||
fmt.Println()
|
||||
|
||||
// Print the list of containers in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tMACHINE"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
|
||||
for _, ctr := range svc.Containers {
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse created time: %w", err)
|
||||
}
|
||||
created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
||||
|
||||
machine := machinesNamesByID[ctr.MachineID]
|
||||
if machine == "" {
|
||||
machine = ctr.MachineID
|
||||
}
|
||||
state, err := ctr.Container.HumanState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get human state: %w", err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(
|
||||
tw,
|
||||
"%s\t%s\t%s\t%s\t%s\n",
|
||||
stringid.TruncateID(ctr.Container.ID),
|
||||
ctr.Container.Config.Image,
|
||||
created,
|
||||
state,
|
||||
machine,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
// TODO(lhf): rename to context
|
||||
var cluster string
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List services.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(cmd.Context(), uncli, cluster)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&cluster, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
client, err := uncli.ConnectCluster(ctx, clusterName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
services, err := client.ListServices(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list services: %w", err)
|
||||
}
|
||||
|
||||
serviceNames := make(map[string]struct{}, len(services))
|
||||
haveDuplicateNames := false
|
||||
for _, svc := range services {
|
||||
if _, exists := serviceNames[svc.Name]; exists {
|
||||
haveDuplicateNames = true
|
||||
break
|
||||
}
|
||||
serviceNames[svc.Name] = struct{}{}
|
||||
}
|
||||
|
||||
// Print the list of services in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
|
||||
// Include the ID column if there are duplicate service names to differentiate them.
|
||||
if haveDuplicateNames {
|
||||
if _, err = fmt.Fprintf(tw, "ID\t"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tENDPOINTS"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
for _, s := range services {
|
||||
endpointsSlice := s.Endpoints()
|
||||
endpoints := strings.Join(endpointsSlice, ", ")
|
||||
|
||||
if haveDuplicateNames {
|
||||
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", s.Name, s.Mode, len(s.Containers), endpoints); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type rmOptions struct {
|
||||
services []string
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewRmCommand() *cobra.Command {
|
||||
opts := rmOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm SERVICE [SERVICE...]",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Remove one or more services.",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.services = args
|
||||
return rm(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
for _, s := range opts.services {
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err = client.RemoveService(ctx, s); err != nil {
|
||||
return fmt.Errorf("remove service '%s': %w", s, err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), "Removing service "+s)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "service",
|
||||
Aliases: []string{"svc"},
|
||||
Short: "Manage services in an Uncloud cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewInspectCommand(),
|
||||
NewListCommand(),
|
||||
NewRmCommand(),
|
||||
NewRunCommand(),
|
||||
NewScaleCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
dockeropts "github.com/docker/cli/opts"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/daemon/names"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type runOptions struct {
|
||||
command []string
|
||||
cpu dockeropts.NanoCPUs
|
||||
entrypoint string
|
||||
entrypointChanged bool
|
||||
env []string
|
||||
image string
|
||||
machines []string
|
||||
memory dockeropts.MemBytes
|
||||
mode string
|
||||
name string
|
||||
privileged bool
|
||||
publish []string
|
||||
pull string
|
||||
replicas uint
|
||||
user string
|
||||
volumes []string
|
||||
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewRunCommand() *cobra.Command {
|
||||
opts := runOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "run IMAGE [COMMAND...]",
|
||||
Short: "Run a service.",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
opts.entrypointChanged = cmd.Flag("entrypoint").Changed
|
||||
opts.image = args[0]
|
||||
if len(args) > 1 {
|
||||
opts.command = args[1:]
|
||||
}
|
||||
|
||||
return run(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().VarP(&opts.cpu, "cpu", "",
|
||||
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+
|
||||
"0.5 for half a core or 2.25 for two and a quarter cores.")
|
||||
cmd.Flags().StringVar(&opts.entrypoint, "entrypoint", "",
|
||||
"Overwrite the default ENTRYPOINT of the image. Pass an empty string \"\" to reset it.")
|
||||
cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil,
|
||||
"Set an environment variable for service containers. Can be specified multiple times.\n"+
|
||||
"Format: VAR=value or just VAR to use the value from the local environment.")
|
||||
cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
|
||||
fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+
|
||||
"the machines) or '%s' (one container on every machine).",
|
||||
api.ServiceModeReplicated, api.ServiceModeGlobal))
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Placement constraint by machine names, limiting which machines the service can run on. Can be specified "+
|
||||
"multiple times or as a comma-separated list of machine names. (default is any suitable machine)")
|
||||
cmd.Flags().VarP(&opts.memory, "memory", "",
|
||||
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix "+
|
||||
"(b, k, m, g). Default unit is bytes if no suffix specified.\n"+
|
||||
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
|
||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
||||
"Assign a name to the service. A random name is generated if not specified.")
|
||||
cmd.Flags().BoolVar(&opts.privileged, "privileged", false,
|
||||
"Give extended privileges to service containers. This is a security risk and should be used with caution.")
|
||||
cmd.Flags().StringSliceVarP(&opts.publish, "publish", "p", nil,
|
||||
"Publish a service port to make it accessible outside the cluster. Can be specified multiple times.\n"+
|
||||
"Format: [hostname:][load_balancer_port:]container_port[/protocol] or [host_ip:]:host_port:container_port[/protocol]@host\n"+
|
||||
"Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified\n"+
|
||||
"and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+
|
||||
"Examples:\n"+
|
||||
" -p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname\n"+
|
||||
" -p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname\n"+
|
||||
// TODO: add support for publishing L4 tcp/udp ports.
|
||||
//" -p 9000:8080 Publish port 8080 as TCP port 9000 via reverse proxy\n"+
|
||||
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
|
||||
cmd.Flags().StringVar(&opts.pull, "pull", api.PullPolicyMissing,
|
||||
fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').",
|
||||
api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever))
|
||||
cmd.Flags().UintVar(&opts.replicas, "replicas", 1,
|
||||
"Number of containers to run for the service. Only valid for a replicated service.")
|
||||
cmd.Flags().StringVarP(&opts.user, "user", "u", "",
|
||||
"User name or UID and optionally group name or GID used for running the command inside service containers.\n"+
|
||||
"Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.")
|
||||
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
|
||||
"Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+
|
||||
"the volume is located. Can be specified multiple times.\n"+
|
||||
"Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]\n"+
|
||||
"Examples:\n"+
|
||||
" -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container\n"+
|
||||
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
|
||||
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "context", "c", "",
|
||||
"Name of the cluster context to run the service in. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
||||
spec, err := prepareServiceSpec(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
var resp api.RunServiceResponse
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
resp, err = clusterClient.RunService(ctx, spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Running service %s (%s mode)", spec.Name, spec.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := clusterClient.InspectService(ctx, resp.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect service: %w", err)
|
||||
}
|
||||
|
||||
endpoints := svc.Endpoints()
|
||||
if len(endpoints) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s endpoints:\n", svc.Name)
|
||||
for _, endpoint := range endpoints {
|
||||
fmt.Printf(" • %s\n", endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
||||
var spec api.ServiceSpec
|
||||
|
||||
env, err := parseEnv(opts.env)
|
||||
if err != nil {
|
||||
return spec, err
|
||||
}
|
||||
|
||||
switch opts.mode {
|
||||
case api.ServiceModeReplicated, api.ServiceModeGlobal:
|
||||
default:
|
||||
return spec, fmt.Errorf("invalid replication mode: '%s'", opts.mode)
|
||||
}
|
||||
|
||||
switch opts.pull {
|
||||
case api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever:
|
||||
default:
|
||||
return spec, fmt.Errorf("invalid pull policy: '%s'", opts.pull)
|
||||
}
|
||||
|
||||
ports := make([]api.PortSpec, len(opts.publish))
|
||||
for i, publishPort := range opts.publish {
|
||||
port, err := api.ParsePortSpec(publishPort)
|
||||
if err != nil {
|
||||
return spec, fmt.Errorf("invalid service port '%s': %w", publishPort, err)
|
||||
}
|
||||
ports[i] = port
|
||||
}
|
||||
|
||||
volumes, mounts, err := parseVolumeFlags(opts.volumes)
|
||||
if err != nil {
|
||||
return spec, err
|
||||
}
|
||||
|
||||
placement := api.Placement{
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||
}
|
||||
|
||||
spec = api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Command: opts.command,
|
||||
Env: env,
|
||||
Image: opts.image,
|
||||
Privileged: opts.privileged,
|
||||
PullPolicy: opts.pull,
|
||||
Resources: api.ContainerResources{
|
||||
CPU: opts.cpu.Value(),
|
||||
Memory: opts.memory.Value(),
|
||||
},
|
||||
User: opts.user,
|
||||
VolumeMounts: mounts,
|
||||
},
|
||||
Mode: opts.mode,
|
||||
Name: opts.name,
|
||||
Placement: placement,
|
||||
Ports: ports,
|
||||
Replicas: opts.replicas,
|
||||
Volumes: volumes,
|
||||
}
|
||||
|
||||
// Overwrite the default ENTRYPOINT of the image or reset it if an empty string is passed.
|
||||
if opts.entrypoint != "" {
|
||||
spec.Container.Entrypoint = []string{opts.entrypoint}
|
||||
} else if opts.entrypointChanged {
|
||||
spec.Container.Entrypoint = []string{""}
|
||||
}
|
||||
|
||||
if err = spec.Validate(); err != nil {
|
||||
return spec, fmt.Errorf("invalid service configuration: %w", err)
|
||||
}
|
||||
|
||||
// Generate a service name if not specified to be able to include it in the progress title.
|
||||
if spec.Name == "" {
|
||||
spec.Name, err = deploy.GenerateServiceName(spec.Container.Image)
|
||||
if err != nil {
|
||||
return spec, fmt.Errorf("generate service name: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return spec, err
|
||||
}
|
||||
|
||||
// parseEnv parses the environment variables from the command line arguments.
|
||||
// It supports two formats: "VAR=value" or just "VAR" to use the value from the local environment.
|
||||
func parseEnv(env []string) (api.EnvVars, error) {
|
||||
envVars := make(api.EnvVars)
|
||||
for _, e := range env {
|
||||
key, value, hasValue := strings.Cut(e, "=")
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("invalid environment variable: '%s'", e)
|
||||
}
|
||||
|
||||
if hasValue {
|
||||
envVars[key] = value
|
||||
} else {
|
||||
if localEnvValue, ok := os.LookupEnv(key); ok {
|
||||
envVars[key] = localEnvValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return envVars, nil
|
||||
}
|
||||
|
||||
// parseVolumeFlags parses volume flag values in Docker CLI format and returns VolumeSpecs and VolumeMounts.
|
||||
// It handles both named volumes (volume_name:/container/path[:ro|volume-nocopy])
|
||||
// and bind mounts (/host/path:/container/path[:ro]).
|
||||
func parseVolumeFlags(volumes []string) ([]api.VolumeSpec, []api.VolumeMount, error) {
|
||||
specs := make([]api.VolumeSpec, 0, len(volumes))
|
||||
mounts := make([]api.VolumeMount, 0, len(volumes))
|
||||
|
||||
// Track volume names to avoid duplicate specs.
|
||||
seenVolumes := make(map[string]struct{})
|
||||
|
||||
for _, vol := range volumes {
|
||||
spec, mount, err := parseVolumeFlagValue(vol)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid volume mount '%s': %w", vol, err)
|
||||
}
|
||||
|
||||
if _, ok := seenVolumes[spec.Name]; !ok {
|
||||
specs = append(specs, spec)
|
||||
seenVolumes[spec.Name] = struct{}{}
|
||||
}
|
||||
|
||||
mounts = append(mounts, mount)
|
||||
}
|
||||
|
||||
return specs, mounts, nil
|
||||
}
|
||||
|
||||
func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error) {
|
||||
var spec api.VolumeSpec
|
||||
var mount api.VolumeMount
|
||||
|
||||
parts := strings.Split(volume, ":")
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
return spec, mount, fmt.Errorf("invalid format, must contain at least one separator ':'")
|
||||
case 2, 3:
|
||||
// Format: (volume_name|/host/path):/container/path[:opts]
|
||||
if !strings.HasPrefix(parts[1], "/") {
|
||||
return spec, mount, fmt.Errorf("invalid container mount path: '%s', must be absolute path", parts[1])
|
||||
}
|
||||
|
||||
mount.ContainerPath = parts[1]
|
||||
volumeNoCopy := false
|
||||
|
||||
if len(parts) == 3 {
|
||||
opts := strings.Split(parts[2], ",")
|
||||
for _, opt := range opts {
|
||||
switch opt {
|
||||
case "ro", "readonly":
|
||||
mount.ReadOnly = true
|
||||
case "volume-nocopy":
|
||||
volumeNoCopy = true
|
||||
default:
|
||||
return spec, mount, fmt.Errorf("invalid option: '%s'", opt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(parts[0], "/") {
|
||||
// Host path bind mount: /host/path:/container/path
|
||||
suffix, err := secret.RandomAlphaNumeric(4)
|
||||
if err != nil {
|
||||
return spec, mount, fmt.Errorf("generate random suffix: %w", err)
|
||||
}
|
||||
|
||||
spec = api.VolumeSpec{
|
||||
Name: "bind-" + suffix,
|
||||
Type: api.VolumeTypeBind,
|
||||
BindOptions: &api.BindOptions{
|
||||
HostPath: parts[0],
|
||||
CreateHostPath: true,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Named volume mount: volume_name:/container/path
|
||||
volumeName := parts[0]
|
||||
if !names.RestrictedNamePattern.MatchString(volumeName) {
|
||||
return spec, mount, fmt.Errorf("volume name '%s' includes invalid characters, only '%s' are allowed. "+
|
||||
"If you intended to pass a host directory or file, use absolute path",
|
||||
volumeName, names.RestrictedNameChars)
|
||||
}
|
||||
|
||||
spec = api.VolumeSpec{
|
||||
Name: volumeName,
|
||||
Type: api.VolumeTypeVolume,
|
||||
VolumeOptions: &api.VolumeOptions{
|
||||
Name: volumeName,
|
||||
NoCopy: volumeNoCopy,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
mount.VolumeName = spec.Name
|
||||
default:
|
||||
return spec, mount, fmt.Errorf("invalid format, must container at most 2 separators ':'")
|
||||
}
|
||||
|
||||
return spec, mount, nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type scaleOptions struct {
|
||||
service string
|
||||
replicas uint
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewScaleCommand() *cobra.Command {
|
||||
opts := scaleOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "scale SERVICE REPLICAS",
|
||||
Short: "Scale a replicated service by changing the number of replicas.",
|
||||
Long: "Scale a replicated service by changing the number of replicas. Scaling down requires confirmation.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
opts.service = args[0]
|
||||
replicas, err := strconv.ParseUint(args[1], 10, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid number of replicas: %w", err)
|
||||
}
|
||||
opts.replicas = uint(replicas)
|
||||
|
||||
return scale(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
||||
if opts.replicas == 0 {
|
||||
return fmt.Errorf(
|
||||
"scaling to zero replicas is not supported. This would effectively remove the service without preserving "+
|
||||
"its configuration, making it impossible to scale back up. Uncloud derives the service configuration "+
|
||||
"from existing containers. Use 'uc rm %s' instead if you want to remove the service",
|
||||
opts.service,
|
||||
)
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
svc, err := clusterClient.InspectService(ctx, opts.service)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect service '%s': %w", opts.service, err)
|
||||
}
|
||||
|
||||
if svc.Mode != api.ServiceModeReplicated {
|
||||
return fmt.Errorf("scaling is only supported for services in %s mode, service '%s' is in %s mode",
|
||||
api.ServiceModeReplicated, svc.Name, svc.Mode)
|
||||
}
|
||||
|
||||
currentReplicas := uint(len(svc.Containers))
|
||||
|
||||
if currentReplicas == opts.replicas {
|
||||
fmt.Printf("Service '%s' already has %d replicas. No changes required.\n", svc.Name, currentReplicas)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Check if all containers have the same spec. If not, prompt user to choose which one to scale.
|
||||
// This can happen if a service deployment failed midway and some containers were not updated.
|
||||
spec := svc.Containers[0].Container.ServiceSpec
|
||||
spec.Replicas = opts.replicas
|
||||
deployment := clusterClient.NewDeployment(spec, nil)
|
||||
plan, err := deployment.Plan(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan deployment: %w", err)
|
||||
}
|
||||
|
||||
if len(plan.Operations) == 0 {
|
||||
fmt.Printf("Service '%s' is already scaled to %d replicas.\n", svc.Name, opts.replicas)
|
||||
return nil
|
||||
}
|
||||
|
||||
if opts.replicas < currentReplicas {
|
||||
// Initialise a machine and container name resolver to properly format the plan output.
|
||||
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas)
|
||||
fmt.Println(plan.Format(resolver))
|
||||
fmt.Println()
|
||||
|
||||
// Ask for confirmation before scaling down as it may cause data loss.
|
||||
confirmed, err := confirm()
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm scaling: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Println("Cancelled. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas)
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if _, err = deployment.Run(ctx); err != nil {
|
||||
return fmt.Errorf("deploy service: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirm() (bool, error) {
|
||||
var confirmed bool
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(
|
||||
"Do you want to continue?",
|
||||
).
|
||||
Affirmative("Yes!").
|
||||
Negative("No").
|
||||
Value(&confirmed),
|
||||
),
|
||||
).WithAccessible(true)
|
||||
if err := form.Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return confirmed, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type createOptions struct {
|
||||
driver string
|
||||
driverOpts []string
|
||||
labels []string
|
||||
machine string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
opts := createOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create VOLUME_NAME",
|
||||
Short: "Create a volume on a specific machine.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.driver = strings.TrimSpace(opts.driver)
|
||||
|
||||
volumeName := args[0]
|
||||
if volumeName == "" {
|
||||
return fmt.Errorf("volume name is required")
|
||||
}
|
||||
|
||||
return create(cmd.Context(), uncli, volumeName, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.driver, "driver", "d", "local",
|
||||
"Volume driver to use.")
|
||||
cmd.Flags().StringSliceVarP(&opts.driverOpts, "opt", "o", nil,
|
||||
"Driver specific options in the form of 'key=value' pairs. Can be specified multiple times.")
|
||||
cmd.Flags().StringSliceVarP(&opts.labels, "label", "l", nil,
|
||||
"Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.")
|
||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||
"Name or ID of the machine to create the volume on.")
|
||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Parse driver options.
|
||||
driverOpts := make(map[string]string)
|
||||
for _, opt := range opts.driverOpts {
|
||||
k, v, ok := strings.Cut(opt, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid driver option format: '%s' (expected key=value)", opt)
|
||||
}
|
||||
driverOpts[k] = v
|
||||
}
|
||||
|
||||
// Parse labels.
|
||||
labels := make(map[string]string)
|
||||
for _, label := range opts.labels {
|
||||
k, v, ok := strings.Cut(label, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid label format: '%s' (expected key=value)", label)
|
||||
}
|
||||
labels[k] = v
|
||||
}
|
||||
|
||||
// List machines and filter by the specified machine name or ID.
|
||||
// If no machine is specified, prompt the user to select one.
|
||||
machines, err := client.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
var selectedMachine *pb.MachineInfo
|
||||
|
||||
if opts.machine == "" {
|
||||
if len(machines) == 1 {
|
||||
selectedMachine = machines[0].Machine
|
||||
} else {
|
||||
if selectedMachine, err = promptSelectMachine(ctx, machines); err != nil {
|
||||
return fmt.Errorf("select machine: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m := machines.FindByNameOrID(opts.machine)
|
||||
if m == nil {
|
||||
return fmt.Errorf("machine '%s' not found", opts.machine)
|
||||
}
|
||||
selectedMachine = m.Machine
|
||||
}
|
||||
|
||||
createOpts := volume.CreateOptions{
|
||||
Name: name,
|
||||
Driver: opts.driver,
|
||||
DriverOpts: driverOpts,
|
||||
Labels: labels,
|
||||
}
|
||||
vol, err := client.CreateVolume(ctx, selectedMachine.Id, createOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create volume '%s' on machine '%s': %w", name, selectedMachine.Name, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Volume '%s' created on machine '%s'.\n", vol.Volume.Name, vol.MachineName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptSelectMachine(ctx context.Context, machines api.MachineMembersList) (*pb.MachineInfo, error) {
|
||||
options := make([]huh.Option[*pb.MachineInfo], len(machines))
|
||||
for i, m := range machines {
|
||||
options[i] = huh.NewOption(m.Machine.Name, m.Machine)
|
||||
}
|
||||
slices.SortFunc(options, func(a, b huh.Option[*pb.MachineInfo]) int {
|
||||
return strings.Compare(a.Key, b.Key)
|
||||
})
|
||||
|
||||
var selected *pb.MachineInfo
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[*pb.MachineInfo]().
|
||||
Title("Select a machine to create the volume on (or specify with --machine flag)").
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
)
|
||||
if err := form.RunWithContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type inspectOptions struct {
|
||||
machine string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewInspectCommand() *cobra.Command {
|
||||
opts := inspectOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "inspect VOLUME_NAME",
|
||||
Short: "Display detailed information on a volume.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return inspect(cmd.Context(), uncli, args[0], opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||
"Name or ID of the machine where the volume is located. "+
|
||||
"If not specified, the volume will be searched across all machines.")
|
||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
filter := &api.VolumeFilter{
|
||||
Names: []string{name},
|
||||
}
|
||||
if opts.machine != "" {
|
||||
filter.Machines = []string{opts.machine}
|
||||
}
|
||||
|
||||
volumes, err := client.ListVolumes(ctx, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list volumes: %w", err)
|
||||
}
|
||||
|
||||
if len(volumes) == 0 {
|
||||
if opts.machine != "" {
|
||||
return fmt.Errorf("volume '%s' not found on machine '%s'", name, opts.machine)
|
||||
}
|
||||
return fmt.Errorf("volume '%s' not found on any machine", name)
|
||||
}
|
||||
if len(volumes) > 1 {
|
||||
fmt.Printf("Volume '%s' found on multiple machines:\n", name)
|
||||
for _, v := range volumes {
|
||||
fmt.Printf(" • %s\n", v.MachineName)
|
||||
}
|
||||
return errors.New("specify --machine flag to choose which machine to use")
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(volumes[0], "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal volume: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listOptions struct {
|
||||
machines []string
|
||||
quiet bool
|
||||
context string
|
||||
}
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
opts := listOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List volumes across all machines in the cluster.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Filter volumes by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
|
||||
"(default is include all machines)")
|
||||
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
|
||||
"Only display volume names.")
|
||||
|
||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Apply machine filter if specified.
|
||||
var filter *api.VolumeFilter
|
||||
if len(opts.machines) > 0 {
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
filter = &api.VolumeFilter{
|
||||
Machines: machines,
|
||||
}
|
||||
}
|
||||
|
||||
volumes, err := client.ListVolumes(ctx, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list volumes: %w", err)
|
||||
}
|
||||
|
||||
if len(volumes) == 0 {
|
||||
if !opts.quiet {
|
||||
fmt.Println("No volumes found.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort the volumes by name first, then by machine name.
|
||||
slices.SortFunc(volumes, func(a, b api.MachineVolume) int {
|
||||
cmp := strings.Compare(a.Volume.Name, b.Volume.Name)
|
||||
if cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
return strings.Compare(a.MachineName, b.MachineName)
|
||||
})
|
||||
|
||||
// If quiet mode, just print volume names.
|
||||
if opts.quiet {
|
||||
for _, v := range volumes {
|
||||
fmt.Println(v.Volume.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print the volumes in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
fmt.Fprintln(tw, "NAME\tDRIVER\tMACHINE")
|
||||
|
||||
for _, v := range volumes {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n",
|
||||
v.Volume.Name,
|
||||
v.Volume.Driver,
|
||||
v.MachineName,
|
||||
)
|
||||
}
|
||||
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type removeOptions struct {
|
||||
force bool
|
||||
machines []string
|
||||
yes bool
|
||||
context string
|
||||
}
|
||||
|
||||
func NewRemoveCommand() *cobra.Command {
|
||||
opts := removeOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm VOLUME_NAME [VOLUME_NAME...]",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Remove one or more volumes.",
|
||||
Long: "Remove one or more volumes. You cannot remove a volume that is in use by a container.",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return remove(cmd.Context(), uncli, args, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&opts.force, "force", "f", false,
|
||||
"Force the removal of one or more volumes.")
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Name or ID of the machine to remove one or more volumes from. "+
|
||||
"Can be specified multiple times or as a comma-separated list.\n"+
|
||||
"If not specified, the found volume(s) will be removed from all machines.")
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Do not prompt for confirmation before removing the volume(s).")
|
||||
|
||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
filter := &api.VolumeFilter{
|
||||
Names: names,
|
||||
}
|
||||
|
||||
if len(opts.machines) > 0 {
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
filter.Machines = machines
|
||||
}
|
||||
|
||||
volumes, err := client.ListVolumes(ctx, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list volumes: %w", err)
|
||||
}
|
||||
|
||||
if len(volumes) == 0 {
|
||||
if len(names) == 1 {
|
||||
return fmt.Errorf("volume '%s' not found", names[0])
|
||||
}
|
||||
return fmt.Errorf("no volumes found matching the specified names")
|
||||
}
|
||||
|
||||
// Confirm removal if not using --yes flag.
|
||||
if !opts.yes {
|
||||
fmt.Println("The following volumes will be removed:")
|
||||
for _, v := range volumes {
|
||||
fmt.Printf(" • '%s' on machine '%s'\n", v.Volume.Name, v.MachineName)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
confirmed, err := cli.Confirm()
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm removal: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Println("Cancelled. No volumes were removed.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the volumes one by one collecting errors.
|
||||
var removeErr error
|
||||
for _, v := range volumes {
|
||||
if err = client.RemoveVolume(ctx, v.MachineID, v.Volume.Name, opts.force); err != nil {
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
removeErr = errors.Join(removeErr, fmt.Errorf("failed to remove volume '%s' on machine '%s': %w",
|
||||
v.Volume.Name, v.MachineName, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Volume '%s' removed from machine '%s'.\n", v.Volume.Name, v.MachineName)
|
||||
}
|
||||
|
||||
return removeErr
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "volume",
|
||||
Short: "Manage volumes in an Uncloud cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewCreateCommand(),
|
||||
NewInspectCommand(),
|
||||
NewListCommand(),
|
||||
NewRemoveCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
+11
-5
@@ -2,22 +2,29 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/spf13/cobra"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"uncloud/internal/daemon"
|
||||
"uncloud/internal/machine"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/daemon"
|
||||
"github.com/psviderski/uncloud/internal/log"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
logger := slog.New(log.NewSlogTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
var dataDir string
|
||||
cmd := &cobra.Command{
|
||||
Use: "uncloudd",
|
||||
Short: "Uncloud machine daemon.",
|
||||
Version: version.String(),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -48,5 +55,4 @@ func main() {
|
||||
}()
|
||||
|
||||
cobra.CheckErr(cmd.ExecuteContext(ctx))
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Ignore everything and use an allowlist of what is allowed to not package any secrets by accident.
|
||||
*
|
||||
|
||||
# Allow files and directories.
|
||||
!docs/
|
||||
!src/
|
||||
!static/
|
||||
!pkg/
|
||||
!Caddyfile
|
||||
!*.js
|
||||
!*.json
|
||||
|
||||
# Ignore unnecessary files inside allowed directories.
|
||||
**/.DS_Store
|
||||
@@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
admin off
|
||||
}
|
||||
|
||||
:8000 {
|
||||
root * /usr/share/caddy
|
||||
file_server
|
||||
try_files {path} /index.html
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Stage 1: Base image.
|
||||
## Start with a base image containing NodeJS so we can build Docusaurus.
|
||||
FROM node:lts-alpine AS base
|
||||
## Disable colour output from yarn to make logs easier to read.
|
||||
ENV FORCE_COLOR=0
|
||||
## Enable corepack.
|
||||
RUN corepack enable
|
||||
## Set the working directory to `/opt/docusaurus`.
|
||||
WORKDIR /opt/docusaurus
|
||||
|
||||
# Stage 2: Production build.
|
||||
FROM base AS prod
|
||||
## Set the working directory to `/opt/docusaurus`.
|
||||
WORKDIR /opt/docusaurus
|
||||
## Copy over the source code.
|
||||
COPY . /opt/docusaurus/
|
||||
## Install dependencies with `--immutable` to ensure reproducibility.
|
||||
RUN npm ci
|
||||
## Build the static site.
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Serve static site with Caddy.
|
||||
FROM caddy:2.10.0-alpine AS caddy
|
||||
## Copy the Caddyfile.
|
||||
COPY ./Caddyfile /etc/caddy/Caddyfile
|
||||
## Copy the Docusaurus build output.
|
||||
COPY --from=prod /opt/docusaurus/build /usr/share/caddy
|
||||
@@ -0,0 +1,41 @@
|
||||
# Website
|
||||
|
||||
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
### Build
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
### Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```
|
||||
$ USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```
|
||||
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
@@ -0,0 +1,439 @@
|
||||
# WireGuard overlay network for Docker containers
|
||||
|
||||
# How to connect Docker containers across multiple hosts using WireGuard
|
||||
|
||||
# Connect Docker containers across multiple hosts with WireGuard
|
||||
|
||||
You have Docker containers running on different Linux machines. You want container A on one machine to talk directly to
|
||||
container B on another machine using their private IPs. For example, to run your application and database containers on
|
||||
separate machines without exposing them publicly. Here's how you can use pure WireGuard and some networking tricks to
|
||||
make this work.
|
||||
|
||||
I implemented this technique to enable cross-machine container communication in
|
||||
[Uncloud](https://github.com/psviderski/uncloud), an open source clustering and deployment tool for Docker.
|
||||
|
||||
* [What we're building](#what-were-building)
|
||||
* [Prequisites](#prerequisites)
|
||||
* [Step 1: Configure Docker networks](#step-1-configure-docker-networks)
|
||||
* [Step 2: Connect Docker networks with WireGuard](#step-2-connect-docker-networks-with-wireguard)
|
||||
* [Step 3: Configure IP routing](#step-3-configure-ip-routing)
|
||||
* [Step 4: Testing](#step-4-testing)
|
||||
* [Step 5: Make the configuration persistent](#step-5-make-the-configuration-persistent)
|
||||
* [Scaling beyond two machines and limitations](#scaling-beyond-two-machines-and-limitations)
|
||||
* [Automating with Uncloud](#automating-with-uncloud)
|
||||
* [Alternative solutions](#alternative-solutions)
|
||||
* [Conclusion](#conclusion)
|
||||
|
||||
## What we're building
|
||||
|
||||
Docker containers are typically connected to a [bridge network](https://docs.docker.com/engine/network/drivers/bridge/)
|
||||
on their host machine, which allows them to communicate with each other. A bridge network also provides isolation from
|
||||
containers not connected to it and other networks on the host. What we want to achieve is to connect these bridge
|
||||
networks across machines so that containers on different machines can communicate as if they were connected to the same
|
||||
local bridge network.
|
||||
|
||||
The incantation we need is called a site-to-site VPN. Any solution would work. Moreover, if the machines are on the same
|
||||
local network, they're already connected and only miss the appropriate routing configuration. But I'll describe a more
|
||||
versatile approach that works even when the machines are on different continents or behind NAT. WireGuard is the ideal
|
||||
solution for this use case: it's lightweight, [fast](https://www.wireguard.com/performance/), simple to configure,
|
||||
provides [strong security](https://www.wireguard.com/protocol/) and NAT traversal.
|
||||
|
||||
We'll create a new Docker bridge network `multi-host` on each machine with unique subnets. Then establish a secure
|
||||
WireGuard tunnel between the machines and configure IP routing so that `multi-host` bridge networks become routable via
|
||||
the tunnel. Finally, we'll run containers on each machine connected to the `multi-host` network and test that they can
|
||||
communicate with each other using their private IPs.
|
||||
|
||||
I will use these two machines:
|
||||
|
||||
* Machine 1: Debian 12 virtual machine in my homelab network in Australia which is behind NAT
|
||||
* Machine 2: Ubuntu 24.04 server from Hetzner in Finland that has a public IP
|
||||
|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
* Basic knowledge of [Docker networking](https://docs.docker.com/network/) and [WireGuard](https://www.wireguard.com/).
|
||||
If you're new to these topics, you might want to read up on them first.
|
||||
* At least two Linux machines with root access and Docker installed. They should be on the same network or reachable
|
||||
over the internet.
|
||||
|
||||
# Step 1: Configure Docker networks
|
||||
|
||||
Most of the commands in this guide require root privileges. You can run them with `sudo` or log in as root. I'll start
|
||||
root shells on both machines with `sudo -i` for convenience.
|
||||
|
||||
We can't connect the default [Docker bridge networks](https://docs.docker.com/engine/network/drivers/bridge/) across
|
||||
machines because they use the same subnet (`172.17.0.0/16` by default). We need them to have non-overlapping addresses
|
||||
so that we can set up routing between them later.
|
||||
|
||||
Therefore, let's create new Docker bridge networks on each machine with manually specified unique subnets. You can
|
||||
choose any subnets from
|
||||
the [private IPv4 address ranges](https://en.wikipedia.org/wiki/Private_network#Private_IPv4_addresses)
|
||||
that do not overlap with each other or with your existing networks. I'll use `10.200.1.0/24` and `10.200.2.0/24`
|
||||
for Machine 1 and Machine 2 respectively. They don't even need to be sequential or be part of the same larger network.
|
||||
However, using a common parent network (like `10.200.0.0/16` in my case) can simplify firewall rules and make it easier
|
||||
to manage more machines later.
|
||||
|
||||
You can use any name for the Docker networks. I'll call them `multi-host` for clarity.
|
||||
|
||||
```shell
|
||||
# Machine 1
|
||||
docker network create --subnet 10.200.1.0/24 -o com.docker.network.bridge.trusted_host_interfaces="wg0" multi-host
|
||||
# Machine 2
|
||||
docker network create --subnet 10.200.2.0/24 -o com.docker.network.bridge.trusted_host_interfaces="wg0" multi-host
|
||||
```
|
||||
|
||||
Starting with Docker 28.2.0 ([PR](https://github.com/moby/moby/pull/49832)), you have to explicitly specify from which
|
||||
host interfaces you
|
||||
allow [direct routing](https://docs.docker.com/engine/network/packet-filtering-firewalls/#direct-routing) to containers
|
||||
in bridge networks. This is done by specifying the `com.docker.network.bridge.trusted_host_interfaces` option when
|
||||
creating the network. In our case, we want to allow routing via the WireGuard interface `wg0` that we be created in the
|
||||
next step.
|
||||
|
||||
Provide this option even if you're using an older Docker version as it'll be required if you upgrade Docker in the
|
||||
future.
|
||||
|
||||
## Step 2: Connect Docker networks with WireGuard
|
||||
|
||||
By default, WireGuard uses the UDP port 51280 for communication. To establish a tunnel, at least one of the machines
|
||||
need to be able to reach the other's port over the internet or local network. Please make sure it's not blocked by a
|
||||
firewall on both machines.
|
||||
|
||||
For example, when using `iptables`, you can allow incoming UDP traffic on port 51820 with the following command:
|
||||
|
||||
```shell
|
||||
iptables -I INPUT -p udp --dport 51820 -j ACCEPT
|
||||
```
|
||||
|
||||
Install WireGuard utilities and generate key pairs on both machines:
|
||||
|
||||
```shell
|
||||
apt update && apt install wireguard
|
||||
# Change the mode for files created in the shell to 0600
|
||||
umask 077
|
||||
# Create 'privatekey' file containing a new private key
|
||||
wg genkey > privatekey
|
||||
# Create 'publickey' file containing the corresponding public key
|
||||
wg pubkey < privatekey > publickey
|
||||
```
|
||||
|
||||
Create WireGuard configuration files using the generated keys.
|
||||
|
||||
On Machine 1, create `/etc/wireguard/wg0.conf`:
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
ListenPort = 51820
|
||||
PrivateKey = <replace with 'privatekey' file content from Machine 1>
|
||||
|
||||
[Peer]
|
||||
PublicKey = <replace with 'publickey' file content from Machine 2>
|
||||
# IP ranges for which a peer will route traffic - Docker subnet on Machine 2
|
||||
AllowedIPs = 10.200.2.0/24
|
||||
# Public IP of Machine 2
|
||||
Endpoint = 157.180.72.195:51820
|
||||
# Periodically send keepalive packets to keep NAT/firewall mapping alive
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
On Machine 2, create `/etc/wireguard/wg0.conf`:
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
ListenPort = 51820
|
||||
PrivateKey = <replace with 'privatekey' file content from Machine 2>
|
||||
|
||||
[Peer]
|
||||
PublicKey = <replace with 'publickey' file content from Machine 1>
|
||||
# IP ranges for which a peer will route traffic - Docker subnet on Machine 1
|
||||
AllowedIPs = 10.200.1.0/24
|
||||
# Reachable endpoint of Machine 1
|
||||
# Endpoint =
|
||||
# Periodically send keepalive packets to keep NAT/firewall mapping alive
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
Refer to the
|
||||
[Unofficial WireGuard Documentation](https://github.com/pirate/wireguard-docs?tab=readme-ov-file#config-reference)
|
||||
for more details on the configuration options.
|
||||
|
||||
Note that the `Endpoint` option could be omitted on one of the machines if the peer is not reachable from that machine.
|
||||
In my case, Machine 1 is behind NAT in my private homelab network which is not reachable from the remote Hetzner
|
||||
server (Machine 2). The bidirectional tunnel can still be established in this case but Machine 1 must initiate the
|
||||
connection.
|
||||
|
||||
If both of your machine are reachable from each other, you should specify the `Endpoint` option in both configs which
|
||||
will allow them to establish the connection without waiting for the other side to initiate it. If both of your machines
|
||||
are behind NAT, see [NAT to NAT Connections](https://github.com/pirate/wireguard-docs#NAT-to-NAT-Connections) for more
|
||||
information.
|
||||
|
||||
Note also that we don't set `Address` option in the configs because we don't want to assign any IP addresses to the
|
||||
WireGuard interfaces. We want the tunnel to only encapsulate and transfer packets from the `multi-host` bridge networks
|
||||
and don't want any end of it to be the destination for the packets.
|
||||
|
||||
As the key pairs are now specified in the configuration files, you can remove the `privatekey` and `publickey` files on
|
||||
both machines:
|
||||
|
||||
```shell
|
||||
rm privatekey publickey
|
||||
```
|
||||
|
||||
Now start the WireGuard interface `wg0` on both machines:
|
||||
|
||||
```shell
|
||||
wg-quick up wg0
|
||||
```
|
||||
|
||||
Verify that the tunnel is up and running on any of the machines:
|
||||
|
||||
```shell
|
||||
$ wg show
|
||||
interface: wg0
|
||||
public key: 4P6scLYcHdgwU8tMkQYGjq6pu4KvrwKyKIg7JuP6E30=
|
||||
private key: (hidden)
|
||||
listening port: 51820
|
||||
|
||||
peer: 0WDgQ+XkHkODI+3xT4APiI9GJS7MvjGH6wtk+W57TgM=
|
||||
endpoint: 157.180.72.195:51820
|
||||
allowed ips: 10.200.2.0/24
|
||||
latest handshake: 12 seconds ago
|
||||
transfer: 124 B received, 624 B sent
|
||||
persistent keepalive: every 25 seconds
|
||||
```
|
||||
|
||||
If you see the `latest handshake` time updating, it means the tunnel is working correctly.
|
||||
|
||||
## Step 3: Configure IP routing
|
||||
|
||||
Docker daemon automatically enables IP forwarding in the kernel when it starts, so you don't need to manually configure
|
||||
`net.ipv4.ip_forward` with `sysctl`.
|
||||
|
||||
However, Docker blocks traffic between external interfaces and container networks by default for security. You need to
|
||||
explicitly allow WireGuard traffic from `wg0` interface to reach your containers via the `multi-host` bridge interface.
|
||||
Docker uses iptables, so you can allow this traffic by adding a rule to the `FORWARD` chain before any other
|
||||
Docker-managed rules that would drop it. Luckily, Docker creates a special `DOCKER-USER` chain exactly for this purpose
|
||||
that the `FORWARD` chain jumps to before jumping to any other Docker-managed chains.
|
||||
|
||||
To create the required iptables rule, you need to find the bridge interface name for the `multi-host` network you
|
||||
created earlier. It's named `br-<short-network-id>`, where `<short-network-id>` is the first 12 characters of the
|
||||
network ID.
|
||||
|
||||
Add the iptables rule to allow traffic from `wg0` to `multi-host` bridge on Machine 1:
|
||||
|
||||
```bash
|
||||
$ docker network ls -f name=multi-host
|
||||
NETWORK ID NAME DRIVER SCOPE
|
||||
661096b2a5d9 multi-host bridge local
|
||||
$ iptables -I DOCKER-USER -i wg0 -o br-661096b2a5d9 -j ACCEPT
|
||||
```
|
||||
|
||||
Add the iptables rule to allow traffic from `wg0` to `multi-host` bridge on Machine 2:
|
||||
|
||||
```bash
|
||||
$ docker network ls -f name=multi-host
|
||||
NETWORK ID NAME DRIVER SCOPE
|
||||
48f808048e7c multi-host bridge local
|
||||
$ iptables -I DOCKER-USER -i wg0 -o br-48f808048e7c -j ACCEPT
|
||||
```
|
||||
|
||||
The traffic the other way around (from `multi-host` bridge to `wg0`) is not blocked by Docker by default. But it still
|
||||
won't be able to make it through the tunnel. The reason is that Docker creates a `MASQUERADE` rule in the `nat` table
|
||||
for every bridge network with option
|
||||
[`com.docker.network.bridge.enable_ip_masquerade`](https://docs.docker.com/engine/network/drivers/bridge/#options) set
|
||||
to `true` (which is the default). In my case, the rule looks like this on Machine 1:
|
||||
|
||||
```
|
||||
POSTROUTING -s 10.200.1.0/24 ! -o br-661096b2a5d9 -j MASQUERADE
|
||||
```
|
||||
|
||||
This essentially configures NAT for all external traffic coming from containers which is necessary to allow them to
|
||||
access the internet and other external networks. However, it equally applies to the traffic going through the `wg0`
|
||||
interface. It tries to masquerade the source IP address of the packets with the IP address of the `wg0` interface and
|
||||
fails because the `wg0` interface doesn't have an IP. This results in the packets being
|
||||
[dropped](https://elixir.bootlin.com/linux/v6.15.5/source/net/netfilter/nf_nat_masquerade.c#L54-L58).
|
||||
|
||||
You cloud assign an IP address to `wg0` but this would cause the following unwanted side effects:
|
||||
|
||||
- Containers from other Docker networks on the same machine could route through the tunnel to reach remote `multi-host`
|
||||
containers, violating Docker's network isolation model.
|
||||
- Remote containers would see all connections as coming from the `wg0` IP instead of the actual container IPs.
|
||||
|
||||
Let's instead add another rule to the `POSTROUTING` chain in the `nat` table to skip masquerading for the traffic from
|
||||
the `multi-host` network going through the tunnel.
|
||||
|
||||
Run on Machine 1:
|
||||
|
||||
```shell
|
||||
iptables -t nat -I POSTROUTING -s 10.200.1.0/24 -o wg0 -j RETURN
|
||||
```
|
||||
|
||||
Run on Machine 2:
|
||||
|
||||
```shell
|
||||
iptables -t nat -I POSTROUTING -s 10.200.2.0/24 -o wg0 -j RETURN
|
||||
```
|
||||
|
||||
## Step 4: Testing
|
||||
|
||||
Now you can finally run containers on both machines connected to their `multi-host` networks and test that they can
|
||||
communicate.
|
||||
|
||||
Run a [whoami](https://hub.docker.com/r/traefik/whoami) container on Machine 2 which listens on port 80 and replies with
|
||||
the OS information and HTTP request that it receives:
|
||||
|
||||
```shell
|
||||
docker run -d --name whoami --network multi-host traefik/whoami
|
||||
```
|
||||
|
||||
Get its IP address:
|
||||
|
||||
```shell
|
||||
$ docker inspect -f "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" whoami
|
||||
10.200.2.2
|
||||
```
|
||||
|
||||
Now fetch `http://10.200.2.2` from inside a container on Machine 1.
|
||||
|
||||
Drum roll, please! 🥁
|
||||
|
||||
```shell
|
||||
$ docker run -it --rm --network multi-host alpine/curl http://10.200.2.2
|
||||
Hostname: bdb55fc9d9ae
|
||||
IP: 127.0.0.1
|
||||
IP: ::1
|
||||
IP: 10.200.2.2
|
||||
RemoteAddr: 10.200.1.2:37682
|
||||
GET / HTTP/1.1
|
||||
Host: 10.200.2.2
|
||||
User-Agent: curl/8.14.1
|
||||
Accept: */*
|
||||
```
|
||||
|
||||
Yay, it works! The request came from the container `10.200.1.2` on Machine 1 and was served by the container
|
||||
`10.200.2.2` on Machine 2.
|
||||
|
||||
You can ping remote containers or use any other network protocols to communicate with them:
|
||||
|
||||
```shell
|
||||
$ docker run -it --rm --network multi-host alpine:latest ping -c 3 10.200.2.2
|
||||
PING 10.200.2.2 (10.200.2.2): 56 data bytes
|
||||
64 bytes from 10.200.2.2: seq=0 ttl=62 time=301.294 ms
|
||||
64 bytes from 10.200.2.2: seq=1 ttl=62 time=297.191 ms
|
||||
64 bytes from 10.200.2.2: seq=2 ttl=62 time=297.285 ms
|
||||
```
|
||||
|
||||
Both hosts have IPs assigned to the `multi-host` bridges, `10.200.1.1` and `10.200.2.1` respectively which should aslo
|
||||
be reachable from the containers or hosts on both machines.
|
||||
|
||||
You can see from the `ping` command the latency is quite high (~300 ms) in my case because the packets have to travel
|
||||
from Australia to Finland and back. You should take this into account when planning to run latency-sensitive
|
||||
applications across machines in different regions. As my friend
|
||||
Sergey [once said](https://x.com/megaserg/status/1857438834822090793), "sucks to be limited by the speed of light tbh".
|
||||
|
||||
## Step 5: Make the configuration persistent
|
||||
|
||||
To ensure this setup survives reboots, you need to:
|
||||
|
||||
1. Persist iptables rules.
|
||||
2. Automatically start the WireGuard interface on boot.
|
||||
|
||||
### Persisting iptables rules
|
||||
|
||||
You can use the `iptables-persistent` package to save and restore iptables rules on boot. But a more reliable way would
|
||||
be to use `PostUp` and `PostDown` options in the WireGuard configs to automatically configure iptables when WireGuard
|
||||
starts/stops.
|
||||
|
||||
Append the following lines to the `[Interface]` section in `/etc/wireguard/wg0.conf`. Make sure to replace
|
||||
`<network-id>` with your actual Docker network ID from Step 3. The `%i` is replaced by WireGuard with the interface
|
||||
name (`wg0`).
|
||||
|
||||
On Machine 1:
|
||||
|
||||
```shell
|
||||
[Interface]
|
||||
...
|
||||
PostUp = iptables -I DOCKER-USER -i %i -o br-<network-id> -j ACCEPT; iptables -t nat -I POSTROUTING -s 10.200.1.0/24 -o %i -j RETURN
|
||||
PostDown = iptables -D DOCKER-USER -i %i -o br-<network-id> -j ACCEPT; iptables -t nat -D POSTROUTING -s 10.200.1.0/24 -o %i -j RETURN
|
||||
```
|
||||
|
||||
On Machine 2:
|
||||
|
||||
```shell
|
||||
[Interface]
|
||||
...
|
||||
PostUp = iptables -I DOCKER-USER -i %i -o br-<network-id> -j ACCEPT; iptables -t nat -I POSTROUTING -s 10.200.2.0/24 -o %i -j RETURN
|
||||
PostDown = iptables -D DOCKER-USER -i %i -o br-<network-id> -j ACCEPT; iptables -t nat -D POSTROUTING -s 10.200.2.0/24 -o %i -j RETURN
|
||||
```
|
||||
|
||||
### Start WireGuard on boot
|
||||
|
||||
The `wireguard-tools` package provides a convenient systemd service to manage WireGuard interfaces. Since our iptables
|
||||
rules should have a priority over Docker's rules, WireGuard must start after Docker.
|
||||
|
||||
Create a systemd drop-in configuration for this:
|
||||
|
||||
```shell
|
||||
mkdir -p /etc/systemd/system/wg-quick@wg0.service.d/
|
||||
cat > /etc/systemd/system/wg-quick@wg0.service.d/docker-dependency.conf << EOF
|
||||
[Unit]
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
EOF
|
||||
```
|
||||
|
||||
Then enable the WireGuard service to start on boot:
|
||||
|
||||
```shell
|
||||
systemctl enable wg-quick@wg0.service
|
||||
systemctl daemon-reload
|
||||
# Verify the unit includes the drop-in configuration.
|
||||
systemctl cat wg-quick@wg0.service
|
||||
```
|
||||
|
||||
## Scaling beyond two machines and limitations
|
||||
|
||||
//Adding a third machine requires updating configs on all existing machines. This gets tedious fast... //WireGuard mesh
|
||||
and challenges to manually manage key pairs and distribute configs //Requirements for NAT traversal: at least one
|
||||
machine in each pair must be reachable by the other. The wireguard will fail to establish a connection if both machines
|
||||
are behind NAT without special tricks that are beyond the scope of this post. DNS resolution for container names across
|
||||
machines is not covered here, but you can use a service discovery tool like Consul.
|
||||
|
||||
## Automating with Uncloud
|
||||
|
||||
//I built Uncloud to handle all the heavy lifting automatically.
|
||||
|
||||
You can initialise a cluster of machines by running the following commands:
|
||||
|
||||
```shell
|
||||
uc machine init user@machine1-ip
|
||||
uc machine add user@machine2-ip
|
||||
...
|
||||
uc machine add user@machineN-ip
|
||||
```
|
||||
|
||||
//This will create `uncloud` Docker bridge network on each machine with `10.210.N.0/24` subnet by default and set up
|
||||
//WireGuard mesh network between them and make persistent across reboots.
|
||||
|
||||
//Mention embedded DNS that resolves container IPs by their service names and multi-machine Docker Compose support.
|
||||
|
||||
## Alternative solutions
|
||||
|
||||
//I wanted to explore only lightweight solutions for Docker so not talking about Kubernetes and a numerous CNI
|
||||
//drivers. Let's leave this beast for another time.
|
||||
|
||||
### Docker Swarm overlay network
|
||||
|
||||
### Flannel
|
||||
|
||||
### Tailscale
|
||||
|
||||
//Not a generic site-to-site VPN, so the recommended approach is to use Tailscale on the container level. This way a
|
||||
//container that needs to talk across machines is configured as a Tailscale machine so it can connect to other Tailscale
|
||||
//machines. Maybe the subnet router feature can be used to connect Docker networks in a similar I described here, but
|
||||
//I haven't tested it.
|
||||
|
||||
## Conclusion
|
||||
|
||||
//Summarise what we've done.?
|
||||
|
||||
If you like this article and my work, you can follow me on X [@psviderski](https://x.com/psviderski).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 897 KiB |
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
uncloud-docs:
|
||||
image: ghcr.io/psviderski/uncloud-docs:latest
|
||||
pull_policy: always
|
||||
user: nobody
|
||||
x-ports:
|
||||
- docs.uncloud.run:8000/https
|
||||
scale: 2
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
slug: /
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
Uncloud makes **self-hosting web applications** across multiple machines in production dead simple.
|
||||
|
||||
You can connect any machines — from cloud VMs to bare metal servers (no matter where they're located) — into a secure
|
||||
private network. Then run and scale multi-service and multi-container web apps and databases across your machines using
|
||||
simple Docker-like commands and [Docker Compose](https://docs.docker.com/reference/compose-file/) files.
|
||||
|
||||
Uncloud covers all the essentials for operating apps in production without overwhelming you with the complexity of
|
||||
traditional container orchestrators like Kubernetes or Swarm:
|
||||
|
||||
* Initial machine and network setup
|
||||
* Zero-downtime rolling deployments
|
||||
* Health checks and automatic restarts
|
||||
* Automatic HTTPS and reverse proxy configuration
|
||||
* Scaling services across multiple machines
|
||||
* Cross-machine service communication without exposing ports to the internet
|
||||
* DNS-based service discovery
|
||||
* Load balancing
|
||||
* Persistent storage
|
||||
|
||||
## Use cases
|
||||
|
||||
Some of the common use cases Uncloud is a great fit for:
|
||||
|
||||
- **Self-hosting and Homelabs**: Run your self-hosted apps on your own hardware. Start with a single machine and add
|
||||
more as your needs grow.
|
||||
- **Outgrowing Docker Compose**: Level up your Docker Compose setup with zero-downtime deployments, replicas across
|
||||
multiple machines for improved reliability, cross-machine service communication, automated reverse proxy management,
|
||||
and more using the same Compose file.
|
||||
- **Small to medium web applications**: Deploy your SaaS product, websites, or personal projects with redundancy across
|
||||
multiple machines for better reliability and your peace of mind.
|
||||
- **Hybrid setups (cloud + on-prem)**: Combine cloud VMs with on-premise for cost savings and data sovereignty — all
|
||||
managed through the same interface.
|
||||
- **Agencies and freelancers**: Host multiple client projects with proper isolation on shared infrastructure, optimising
|
||||
costs and resources.
|
||||
- **Edge computing**: Deploy applications closer to your users for lower latency and better performance.
|
||||
- **Dev/staging environments**: Spin up additional environments for development and testing that mirror production
|
||||
reusing the same Compose configuration.
|
||||
|
||||
## What makes Uncloud different
|
||||
|
||||
Here are the design decisions that make Uncloud stand out:
|
||||
|
||||
### Decentralised design
|
||||
|
||||
You can think of an Uncloud cluster as a **network of Docker hosts** (machines) that are all aware of each other. All
|
||||
machines in the cluster are equal. You can connect to any of them to manage containers on any other machine in the
|
||||
cluster. If a machine or part of the network goes down, the rest of the cluster keeps running.
|
||||
|
||||
There is no centralised control plane, so no need to worry about maintaining a quorum of machines for it. The time saved
|
||||
can be better spent developing and deploying your apps instead.
|
||||
|
||||
### Zero-config overlay network
|
||||
|
||||
Uncloud automatically configures and maintains a secure **WireGuard mesh network** across your machines. It handles key
|
||||
management, peer discovery, and NAT traversal without any manual configuration. This makes it easy to connect machines
|
||||
from different networks and locations, such as cloud VMs, on-premise servers, or your Raspberry Pi at home.
|
||||
|
||||
Docker containers running on different machines get **unique IP addresses** from the cluster network so they can
|
||||
**communicate directly** as if they were on a single machine without opening up any host ports to the internet.
|
||||
|
||||
The design and implementation were highly inspired by
|
||||
Talos [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/).
|
||||
|
||||
### Managed DNS service
|
||||
|
||||
Uncloud can provide **managed DNS records** like `<service-name>.<cluster-id>.cluster.uncloud.run` for your public
|
||||
services through free [Uncloud DNS](https://github.com/psviderski/uncloud-dns) service. You can deploy a service and
|
||||
instantly access it from anywhere with a proper DNS name and HTTPS without any manual DNS configuration. This makes
|
||||
self-hosting much more accessible and simplifies the process of adding your own domain later.
|
||||
|
||||
### No complex orchestration
|
||||
|
||||
Uncloud operations are done using **imperative CLI commands** that have the taste of Docker and Docker Compose. The
|
||||
deployment and scaling commands can output an execution plan that describes what exactly will be changed on your cluster
|
||||
once you approve it. For example, what containers and volumes will be created or removed, and on which machines.
|
||||
|
||||
This gives you full visibility and control over every change with **immediate feedback** when something goes wrong.
|
||||
|
||||
### Minimal resource footprint
|
||||
|
||||
The Uncloud daemon consists of a couple Go and Rust binaries running alongside the Docker daemon on each machine. It
|
||||
needs no more than **150 MB of RAM** and a few percent of a CPU core in small setups. This minimal overhead maximises
|
||||
the system resources available for your apps.
|
||||
|
||||
You can run Uncloud on machines with as little as 512 MB of RAM, assuming you also need some RAM for the OS and Docker,
|
||||
as well as the apps you want to run.
|
||||
|
||||
### Troubleshooting-friendly
|
||||
|
||||
When something goes wrong, you can dive straight into standard Docker containers without layers of abstraction in your
|
||||
way. You can also SSH into any machine and use the regular Linux troubleshooting tools. For example, `ping` service
|
||||
containers by their service names, `curl` service endpoints, or analyse traffic between containers using `wireshark`.
|
||||
|
||||
## Getting started
|
||||
|
||||
Install Uncloud CLI and deploy your first app in minutes:
|
||||
|
||||
* [Install Uncloud CLI](./2-getting-started/1-install-cli.md)
|
||||
* [Deploy demo app](./2-getting-started/2-deploy-demo-app.md)
|
||||
|
||||
## Getting help
|
||||
|
||||
* **Discord community**: Join our [Discord server](https://discord.gg/eR35KQJhPu) for real-time discussions, support,
|
||||
and updates.
|
||||
* **GitHub issues**: Report bugs or request features on our [GitHub repository](https://github.com/psviderski/uncloud).
|
||||
* **Documentation**: Browse the full documentation (this website you're on) for detailed guides and references. Use
|
||||
search to find what you need quickly.
|
||||
* **Newsletter**: Subscribe to the [newsletter](https://uncloud.run/#subscribe) for development updates and early
|
||||
insights.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Install CLI
|
||||
|
||||
Install the Uncloud command-line utility to manage your machines and deploy apps using `uc` commands. You will run `uc`
|
||||
locally so choose the appropriate installation method for your operating system.
|
||||
|
||||
:::info NOTE
|
||||
|
||||
Windows is not natively supported yet, but you can install and run `uc` in a
|
||||
[WSL](https://learn.microsoft.com/en-us/windows/wsl/) terminal by following the instructions for Linux.
|
||||
:::
|
||||
|
||||
## Homebrew (macOS, Linux)
|
||||
|
||||
If you have [Homebrew](https://brew.sh/) package manager installed, this is the recommended installation method on macOS
|
||||
and Linux:
|
||||
|
||||
```shell
|
||||
brew install psviderski/tap/uncloud
|
||||
```
|
||||
|
||||
To upgrade to the latest version:
|
||||
|
||||
```shell
|
||||
brew upgrade uncloud
|
||||
```
|
||||
|
||||
## Install script (macOS, Linux)
|
||||
|
||||
For a quick automated installation, use the install script:
|
||||
|
||||
```shell
|
||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||
```
|
||||
|
||||
The script will:
|
||||
|
||||
- Detect your operating system and architecture
|
||||
- Download the appropriate latest binary from [GitHub releases](https://github.com/psviderski/uncloud/releases)
|
||||
- Install it to `/usr/local/bin/uncloud` using `sudo` (you may need to enter your user password)
|
||||
- Create a shortcut `uc` in `/usr/local/bin` for convenience
|
||||
|
||||
Don't like `curl | sh`? You can download and review the [install script](https://get.uncloud.run/install.sh) first and
|
||||
then run it:
|
||||
|
||||
```shell
|
||||
curl -fsSO https://get.uncloud.run/install.sh
|
||||
cat install.sh
|
||||
sh install.sh
|
||||
```
|
||||
|
||||
## GitHub download (macOS, Linux)
|
||||
|
||||
You can manually download and use a pre-built binary from the
|
||||
[latest release](https://github.com/psviderski/uncloud/releases/latest) on GitHub.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="macOS (Apple Silicon)">
|
||||
```shell
|
||||
curl -L https://github.com/psviderski/uncloud/releases/latest/download/uncloud_macos_arm64.tar.gz | tar xz
|
||||
mv uncloud uc
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="macOS (Intel)">
|
||||
```shell
|
||||
curl -L https://github.com/psviderski/uncloud/releases/latest/download/uncloud_macos_amd64.tar.gz | tar xz
|
||||
mv uncloud uc
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="Linux (AMD 64-bit)">
|
||||
```shell
|
||||
curl -L https://github.com/psviderski/uncloud/releases/latest/download/uncloud_linux_amd64.tar.gz | tar xz
|
||||
mv uncloud uc
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="Linux (ARM 64-bit)">
|
||||
```shell
|
||||
curl -L https://github.com/psviderski/uncloud/releases/latest/download/uncloud_linux_arm64.tar.gz | tar xz
|
||||
mv uncloud uc
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
You can use the `./uc` binary directly from the current directory, or move it to a directory in your system's `PATH`
|
||||
to run it as `uc` from any location.
|
||||
|
||||
For example, move it to `/usr/local/bin` which is a common location for user-installed binaries:
|
||||
|
||||
```shell
|
||||
sudo mv ./uc /usr/local/bin
|
||||
```
|
||||
|
||||
## Verify installation
|
||||
|
||||
After installation, verify that `uc` command is working:
|
||||
|
||||
```shell
|
||||
uc --version
|
||||
```
|
||||
|
||||
## Linux (via package managers)
|
||||
|
||||
### Debian
|
||||
|
||||
Via unofficial repository packages created and maintained at [uncloud-debian](https://github.com/dariogriffo/uncloud-debian/) by @dariogriffo
|
||||
|
||||
You can install uncloud the debian way by running:
|
||||
|
||||
```sh
|
||||
curl -sS https://debian.griffo.io/EA0F721D231FDD3A0A17B9AC7808B4DD62C41256.asc | sudo gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
|
||||
echo "deb https://debian.griffo.io/apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
|
||||
apt install -y uncloud
|
||||
```
|
||||
|
||||
or in the releases page of the repository [here](https://github.com/dariogriffo/uncloud-debian/releases)
|
||||
|
||||
## Next steps
|
||||
|
||||
Now that you have `uc` installed, you're ready to:
|
||||
|
||||
- [Deploy demo app](./2-deploy-demo-app.md)
|
||||
@@ -0,0 +1,315 @@
|
||||
# Deploy demo app
|
||||
|
||||
In this guide, we'll deploy [Excalidraw](https://excalidraw.com) — a popular sketching and diagramming tool — to your
|
||||
Linux server. You'll learn the **basics of Uncloud** and see how simple it is to **run web apps** on your own
|
||||
infrastructure with secure internet access.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, you'll need:
|
||||
|
||||
- **Uncloud CLI** [installed](1-install-cli.md) on your local machine
|
||||
- A **Ubuntu or Debian server** with **public IP address** and **SSH access** (as `root` or a user with `sudo`
|
||||
privileges) using a **private key**.
|
||||
|
||||
:::tip Need a server?
|
||||
|
||||
A small Virtual Private Server (VPS) or dedicated server from providers like [Hetzner](https://www.hetzner.com) or
|
||||
[DigitalOcean](https://www.digitalocean.com) is a great choice for learning Uncloud and running lightweight services. We
|
||||
recommend using a freshly installed server as existing services on ports 80 and 443 can cause conflicts.
|
||||
|
||||
**Minimum requirements:** 1 vCPU, 512 MB RAM, Ubuntu 22.04 or Debian 11, AMD64 (recommended) or ARM64 architecture.
|
||||
Other Linux distributions may work, but haven't been tested yet.
|
||||
|
||||
:::
|
||||
|
||||
## Set up your server
|
||||
|
||||
First, let's turn your server into an Uncloud **machine**. This simply means setting it up so you can deploy and manage
|
||||
services on it using `uc`.
|
||||
|
||||
```shell
|
||||
uc machine init root@<your-server-ip>
|
||||
```
|
||||
|
||||
If the SSH key to access your server isn't added to your [SSH agent](https://www.ssh.com/academy/ssh/agent), specify it
|
||||
with the `-i` flag:
|
||||
|
||||
```shell
|
||||
uc machine init root@<your-server-ip> -i ~/.ssh/id_xxx
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
||||
- Install the latest stable Docker version on your server if it's not already installed
|
||||
- Install the Uncloud daemon on your server
|
||||
- Create a Docker network for Uncloud-managed containers
|
||||
- Deploy [Caddy](https://caddyserver.com/) as your reverse proxy listening on host ports 80 and 443
|
||||
- Reserve a free `xxxxxx.cluster.uncloud.run` subdomain via the Uncloud managed DNS service and point it to your
|
||||
server's IP
|
||||
|
||||
All in about a minute!
|
||||
|
||||
<details>
|
||||
<summary>💡 Expand to see example output</summary>
|
||||
|
||||
```
|
||||
$ uc machine init root@157.180.72.195
|
||||
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
|
||||
⏳ Running Uncloud install script...
|
||||
⏳ Installing Docker...
|
||||
# Executing docker install script, commit: 53a22f61c0628e58e1d6680b49e82993d304b449
|
||||
+ sh -c apt-get -qq update >/dev/null
|
||||
+ sh -c DEBIAN_FRONTEND=noninteractive apt-get -y -qq install ca-certificates curl >/dev/null
|
||||
+ sh -c install -m 0755 -d /etc/apt/keyrings
|
||||
+ sh -c curl -fsSL "https://download.docker.com/linux/ubuntu/gpg" -o /etc/apt/keyrings/docker.asc
|
||||
+ sh -c chmod a+r /etc/apt/keyrings/docker.asc
|
||||
+ sh -c echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable" > /etc/apt/sources.list.d/docker.list
|
||||
+ sh -c apt-get -qq update >/dev/null
|
||||
+ sh -c DEBIAN_FRONTEND=noninteractive apt-get -y -qq install docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-ce-rootless-extras docker-buildx-plugin >/dev/null
|
||||
|
||||
Running kernel seems to be up-to-date.
|
||||
|
||||
No services need to be restarted.
|
||||
|
||||
No containers need to be restarted.
|
||||
|
||||
No user sessions are running outdated binaries.
|
||||
|
||||
No VM guests are running outdated hypervisor (qemu) binaries on this host.
|
||||
+ sh -c docker version
|
||||
Client: Docker Engine - Community
|
||||
Version: 28.2.1
|
||||
API version: 1.50
|
||||
Go version: go1.24.3
|
||||
Git commit: 879ac3f
|
||||
Built: Wed May 28 19:25:01 2025
|
||||
OS/Arch: linux/amd64
|
||||
Context: default
|
||||
|
||||
Server: Docker Engine - Community
|
||||
Engine:
|
||||
Version: 28.2.1
|
||||
API version: 1.50 (minimum version 1.24)
|
||||
Go version: go1.24.3
|
||||
Git commit: 0e2cc22
|
||||
Built: Wed May 28 19:25:01 2025
|
||||
OS/Arch: linux/amd64
|
||||
Experimental: false
|
||||
containerd:
|
||||
Version: 1.7.27
|
||||
GitCommit: 05044ec0a9a75232cad458027ca83437aae3f4da
|
||||
runc:
|
||||
Version: 1.2.5
|
||||
GitCommit: v1.2.5-0-g59923ef
|
||||
docker-init:
|
||||
Version: 0.19.0
|
||||
GitCommit: de40ad0
|
||||
|
||||
================================================================================
|
||||
|
||||
To run Docker as a non-privileged user, consider setting up the
|
||||
Docker daemon in rootless mode for your user:
|
||||
|
||||
dockerd-rootless-setuptool.sh install
|
||||
|
||||
Visit https://docs.docker.com/go/rootless/ to learn about rootless mode.
|
||||
|
||||
|
||||
To run the Docker daemon as a fully privileged service, but granting non-root
|
||||
users access, refer to https://docs.docker.com/go/daemon-access/
|
||||
|
||||
WARNING: Access to the remote API on a privileged Docker daemon is equivalent
|
||||
to root access on the host. Refer to the 'Docker daemon attack surface'
|
||||
documentation for details: https://docs.docker.com/go/attack-surface/
|
||||
|
||||
================================================================================
|
||||
|
||||
✓ Docker installed successfully.
|
||||
✓ Linux user and group 'uncloud' created.
|
||||
⏳ Installing Uncloud binaries...
|
||||
⏳ Downloading uncloudd binary: https://github.com/psviderski/uncloud/releases/latest/download/uncloudd_linux_amd64.tar.gz
|
||||
✓ uncloudd binary installed: /usr/local/bin/uncloudd
|
||||
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
|
||||
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
|
||||
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
|
||||
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
|
||||
⏳ Downloading uncloud-corrosion binary: https://github.com/psviderski/corrosion/releases/latest/download/corrosion-x86_64-unknown-linux-gnu.tar.gz
|
||||
✓ uncloud-corrosion binary installed: /usr/local/bin/uncloud-corrosion
|
||||
✓ Systemd unit file created: /etc/systemd/system/uncloud-corrosion.service
|
||||
⏳ Starting Uncloud machine daemon (uncloud.service)...
|
||||
✓ Uncloud machine daemon started.
|
||||
✓ Uncloud installed on the machine successfully! 🎉
|
||||
Cluster initialised with machine 'machine-dc3c' and saved as context 'default' in your local config (/Users/spy/.config/uncloud/config.yaml)
|
||||
Current cluster context is now 'default'.
|
||||
Waiting for the machine to be ready...
|
||||
|
||||
Reserved cluster domain: 7za6s7.cluster.uncloud.run
|
||||
[+] Deploying service caddy 7/2
|
||||
✔ Container caddy-d7uk on machine-dc3c Started 6.1s
|
||||
✔ Image caddy:2.10.0 on machine-dc3c Pulled 3.7s
|
||||
|
||||
Updating cluster domain records in Uncloud DNS to point to machines running caddy service...
|
||||
[+] Verifying internet access to caddy service 1/1
|
||||
✔ Machine machine-dc3c (157.180.72.195) Reachable 0.7s
|
||||
|
||||
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||
*.7za6s7.cluster.uncloud.run A → 157.180.72.195
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Deploy Excalidraw
|
||||
|
||||
Now that your machine is set up, let's deploy `excalidraw` service from the
|
||||
[official Docker image](https://hub.docker.com/r/excalidraw/excalidraw). The service will publish the container port 80
|
||||
as HTTPS endpoint on the previously reserved domain via Caddy.
|
||||
|
||||
```shell
|
||||
uc run --name excalidraw --publish 80/https excalidraw/excalidraw
|
||||
```
|
||||
|
||||
You'll see the progress of the deployment and the public URL where you can access the service:
|
||||
|
||||
```
|
||||
[+] Running service excalidraw (replicated mode) 2/2
|
||||
✔ Container excalidraw-azpc on machine-dc3c Started 8.9s
|
||||
✔ Image excalidraw/excalidraw on machine-dc3c Pulled 4.7s
|
||||
|
||||
excalidraw endpoints:
|
||||
• https://excalidraw.7za6s7.cluster.uncloud.run → :80
|
||||
```
|
||||
|
||||
## Verify your deployment
|
||||
|
||||
After the service is deployed, use the `uc inspect` command to check its status and details:
|
||||
|
||||
```shell
|
||||
uc inspect excalidraw
|
||||
```
|
||||
|
||||
```
|
||||
ID: 4d2de1600b6ada221a03896cd388836c
|
||||
Name: excalidraw
|
||||
Mode: replicated
|
||||
|
||||
CONTAINER ID IMAGE CREATED STATUS MACHINE
|
||||
fde7ac7f11ad excalidraw/excalidraw About a minute ago Up About a minute (healthy) machine-dc3c
|
||||
```
|
||||
|
||||
In this example, the service has one container running on the machine `machine-dc3c` (our server). The container is up
|
||||
and healthy.
|
||||
|
||||
You can also list all deployed services and their public endpoints using the `uc ls` command:
|
||||
|
||||
```shell
|
||||
uc ls
|
||||
```
|
||||
|
||||
```
|
||||
NAME MODE REPLICAS ENDPOINTS
|
||||
caddy global 1
|
||||
excalidraw replicated 1 https://excalidraw.7za6s7.cluster.uncloud.run → :80
|
||||
```
|
||||
|
||||
You can see `caddy` service listed here. That's your reverse proxy, running as a regular Uncloud service.
|
||||
|
||||
## It's live! Start drawing! ✨
|
||||
|
||||
Open your browser and navigate to the URL shown in the endpoints. It may take a moment for Caddy to obtain a TLS
|
||||
certificate from Let's Encrypt. If it doesn't load immediately, wait a few seconds and try again.
|
||||
|
||||

|
||||
|
||||
You now have:
|
||||
|
||||
- Your **own Excalidraw instance** running on your server
|
||||
- A **public URL** with **automatic HTTPS** you can share with your team and friends
|
||||
- **Full control over your data** — no analytics or tracking
|
||||
|
||||
## Convert to Docker Compose format
|
||||
|
||||
Uncloud supports the [Compose file format](https://docs.docker.com/reference/compose-file/) for defining services. This
|
||||
allows you to version control your deployments, share configurations with your team, and deploy complex multi-service
|
||||
applications with a single command.
|
||||
|
||||
Let's create a `compose.yaml` file in your current directory for the `excalidraw` service we just deployed.
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
excalidraw:
|
||||
image: excalidraw/excalidraw
|
||||
x-ports:
|
||||
- 80/https
|
||||
```
|
||||
|
||||
:::info note
|
||||
|
||||
The `x-ports` key is an Uncloud-specific extension to the Compose file format. It allows you to specify ports that
|
||||
should be published as HTTP(S) endpoints. Uncloud automatically configures the reverse proxy (Caddy) to route traffic to
|
||||
these ports.
|
||||
|
||||
:::
|
||||
|
||||
Now deploy it:
|
||||
|
||||
```shell
|
||||
uc deploy
|
||||
```
|
||||
|
||||
```
|
||||
Services are up to date.
|
||||
```
|
||||
|
||||
Since `excalidraw` service is already running with the same configuration, Uncloud recognises there's nothing to change.
|
||||
We've successfully converted our deployment created with `uc run` to a Compose file.
|
||||
|
||||
## Use your own domain
|
||||
|
||||
Want to use your own domain, for example, `excalidraw.example.com` instead of `excalidraw.7za6s7.cluster.uncloud.run`?
|
||||
|
||||
Add a CNAME record `excalidraw.example.com` in your DNS provider (Cloudflare, Namecheap, etc.) pointing to
|
||||
`excalidraw.7za6s7.cluster.uncloud.run`. Alternatively, you can add an A record pointing to your server's IP.
|
||||
|
||||
Then update the published port `80/https` in `compose.yaml` to use your domain:
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
...
|
||||
x-ports:
|
||||
- excalidraw.example.com:80/https
|
||||
```
|
||||
|
||||
Finally, deploy the changes:
|
||||
|
||||
```shell
|
||||
uc deploy
|
||||
```
|
||||
|
||||
```
|
||||
Deployment plan:
|
||||
- Deploy service [name=excalidraw]
|
||||
- machine-dc3c: Run container [image=excalidraw/excalidraw]
|
||||
- machine-dc3c: Remove container [name=excalidraw-azpc]
|
||||
|
||||
Do you want to continue?
|
||||
|
||||
Choose [y/N]: y
|
||||
Chose: Yes!
|
||||
|
||||
[+] Deploying services 2/2
|
||||
✔ Container excalidraw-0z12 on machine-dc3c Started 3.5s
|
||||
✔ Container excalidraw-azpc on machine-dc3c Removed 3.4s
|
||||
```
|
||||
|
||||
Notice how Uncloud performed a **zero-downtime deployment** — it started the new container with the updated
|
||||
configuration before removing the old one. Your service stayed available throughout the update.
|
||||
|
||||
Give it a moment for Caddy to obtain a TLS certificate, then visit https://excalidraw.example.com.
|
||||
|
||||
## Clean up
|
||||
|
||||
TBD
|
||||
|
||||
## Next steps
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,4 @@
|
||||
label: Getting started
|
||||
collapsed: false # keep the category open by default
|
||||
link:
|
||||
type: generated-index
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 389 KiB |
@@ -0,0 +1,153 @@
|
||||
// @ts-check
|
||||
// `@type` JSDoc annotations allow editor autocompletion and type checking
|
||||
// (when paired with `@ts-check`).
|
||||
// There are various equivalent ways to declare your Docusaurus config.
|
||||
// See: https://docusaurus.io/docs/api/docusaurus-config
|
||||
|
||||
import {themes as prismThemes} from 'prism-react-renderer';
|
||||
|
||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: 'Uncloud Docs',
|
||||
tagline: 'Dinosaurs are cool',
|
||||
favicon: 'img/favicon.png',
|
||||
|
||||
// Set the production url of your site here
|
||||
url: 'https://docs.uncloud.run',
|
||||
// Set the /<baseUrl>/ pathname under which your site is served
|
||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||
baseUrl: '/',
|
||||
|
||||
// GitHub pages deployment config.
|
||||
// If you aren't using GitHub pages, you don't need these.
|
||||
organizationName: '', // Usually your GitHub org/user name.
|
||||
projectName: '', // Usually your repo name.
|
||||
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
|
||||
// Even if you don't use internationalization, you can use this field to set
|
||||
// useful metadata like html lang. For example, if your site is Chinese, you
|
||||
// may want to replace "en" with "zh-Hans".
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
},
|
||||
|
||||
themes: [
|
||||
[
|
||||
'@easyops-cn/docusaurus-search-local',
|
||||
/** @type {import("@easyops-cn/docusaurus-search-local").PluginOptions} */
|
||||
({
|
||||
docsRouteBasePath: '/',
|
||||
hashed: true,
|
||||
highlightSearchTermsOnTargetPage: true,
|
||||
}),
|
||||
],
|
||||
],
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
docs: {
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: 'https://github.com/psviderski/uncloud/edit/main/docs/',
|
||||
// Serve the docs at the site's root.
|
||||
routeBasePath: '/',
|
||||
showLastUpdateTime: true,
|
||||
sidebarPath: './sidebars.js',
|
||||
},
|
||||
// blog: {
|
||||
// showReadingTime: true,
|
||||
// feedOptions: {
|
||||
// type: ['rss', 'atom'],
|
||||
// xslt: true,
|
||||
// },
|
||||
// // Please change this to your repo.
|
||||
// // Remove this to remove the "edit this page" links.
|
||||
// editUrl:
|
||||
// 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||
// // Useful options to enforce blogging best practices
|
||||
// onInlineTags: 'warn',
|
||||
// onInlineAuthors: 'warn',
|
||||
// onUntruncatedBlogPosts: 'warn',
|
||||
// },
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
},
|
||||
}),
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
colorMode: {
|
||||
// The color mode when user first visits the site.
|
||||
defaultMode: "light",
|
||||
// Whether to use the prefers-color-scheme media-query, using user system preferences,
|
||||
// instead of the hardcoded defaultMode.
|
||||
respectPrefersColorScheme: true,
|
||||
},
|
||||
// The meta image URL for the site (og:image and twitter:image meta tags).
|
||||
// Relative to your site's "static" directory. Cannot be SVGs. Can be external URLs too.
|
||||
image: 'img/social-card.png',
|
||||
navbar: {
|
||||
title: 'Uncloud Docs',
|
||||
logo: {
|
||||
alt: 'Uncloud Logo',
|
||||
src: 'img/logo.svg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'search',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/psviderski/uncloud',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'light',
|
||||
links: [
|
||||
{
|
||||
title: 'Uncloud',
|
||||
items: [
|
||||
{
|
||||
html: 'Made with ❤️ by <a href="https://github.com/psviderski">@psviderski</a> in Australia'
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community',
|
||||
items: [
|
||||
{
|
||||
label: 'Discord',
|
||||
href: 'https://discord.gg/eR35KQJhPu',
|
||||
},
|
||||
{
|
||||
label: 'X',
|
||||
href: 'https://x.com/psviderski',
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/psviderski/uncloud',
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.palenight,
|
||||
//darkTheme: prismThemes.dracula,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export default config;
|
||||
Generated
+18434
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.7.0",
|
||||
"@docusaurus/preset-classic": "3.7.0",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.49.2",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.7.0",
|
||||
"@docusaurus/types": "3.7.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
|
||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
- render a sidebar for each doc of that group
|
||||
- provide next/previous navigation
|
||||
|
||||
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||
|
||||
Create as many sidebars as you want.
|
||||
|
||||
@type {import('@docusaurus/plugin-content-docs').SidebarsConfig}
|
||||
*/
|
||||
const sidebars = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
sidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['tutorial-basics/create-a-document'],
|
||||
},
|
||||
],
|
||||
*/
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Any CSS included here will be global. The classic template
|
||||
* bundles Infima by default. Infima is a CSS framework designed to
|
||||
* work well for content-centric websites.
|
||||
*/
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;700&display=swap');
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
:root {
|
||||
--ifm-font-family-base: 'Inter', sans-serif;
|
||||
--ifm-heading-font-family: 'Inter', sans-serif;
|
||||
|
||||
--ifm-color-primary: #006be6;
|
||||
--ifm-color-primary-dark: #0060cf;
|
||||
--ifm-color-primary-darker: #005bc4;
|
||||
--ifm-color-primary-darkest: #004ba1;
|
||||
--ifm-color-primary-light: #0076fd;
|
||||
--ifm-color-primary-lighter: #0a7cff;
|
||||
--ifm-color-primary-lightest: #2c8eff;
|
||||
--ifm-code-font-size: 95%;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme='dark'] {
|
||||
--ifm-color-primary: #3391ee;
|
||||
--ifm-color-primary-dark: #1883ec;
|
||||
--ifm-color-primary-darker: #137be3;
|
||||
--ifm-color-primary-darkest: #1066bb;
|
||||
--ifm-color-primary-light: #4e9ff0;
|
||||
--ifm-color-primary-lighter: #5ba7f1;
|
||||
--ifm-color-primary-lightest: #83bcf5;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.menu {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--ifm-font-weight-normal);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
// Import the original mapper
|
||||
import MDXComponents from '@theme-original/MDXComponents';
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
export default {
|
||||
// Re-use the default mapping
|
||||
...MDXComponents,
|
||||
// Global import the <Tabs> and <TabItem> components
|
||||
Tabs,
|
||||
TabItem,
|
||||
};
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
|
||||
<g>
|
||||
<rect x="0" y="0" width="80" height="80" rx="10" fill="#18181B"/>
|
||||
<path d="M20 32 L20 48 L36 48 L36 32 M60 32 L45 32 L45 48 L60 48" stroke="white" stroke-width="6" fill="none"
|
||||
stroke-linecap="square" stroke-linejoin="miter"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 341 B |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,59 @@
|
||||
# User Guide
|
||||
|
||||
## Initialising a cluster
|
||||
|
||||
To begin setting up a new Uncloud cluster, create the desired nodes. Ensure that their firewall allows the required ports.
|
||||
|
||||
### Ports
|
||||
|
||||
Nodes running Uncloud with a standard configuration must have the following inbound ports allowed in the firewall:
|
||||
|
||||
* 51820/udp (WireGuard meshnet)
|
||||
* 22/tcp (default SSH management port)
|
||||
* 80/tcp (required for challenge if uncloud.run DNS is enabled, pass --no-dns during init to disable)
|
||||
|
||||
In addition, any ports for any workloads you want to expose will need to be open.
|
||||
|
||||
### Configuration
|
||||
|
||||
Uncloud stores its configuration in `~/.config/uncloud/config.yaml`. If you wish to reinitialise a cluster, simply remove it from this config.
|
||||
|
||||
### Initialisation
|
||||
|
||||
Begin by initialising the first node in your cluster with `uc machine init [USER@HOST:PORT]`. If you do not have a need for Caddy reverse proxy, you may disable this feature with `--no-caddy`.
|
||||
|
||||
This command will idempotently install Docker, uncloudd, uncloud-corrosion. If Caddy is enabled, it will set up a reverse proxy. If Uncloud DNS is enabled, it will create a DNS A record for the machine's public IP address under `*.[CLUSTER ID].cluster.uncloud.run`.
|
||||
|
||||
If you wish to uninstall Uncloud and its components, run `uncloud-uninstall`.
|
||||
|
||||
### Adding a node
|
||||
|
||||
Just like the initialisation of the first node, a node can be added to the cluster with `uc machine add`.
|
||||
|
||||
### DNS
|
||||
|
||||
Uncloud (uncloud.run) DNS can be managed with the `uc dns` subcommand.
|
||||
|
||||
* To reserve a domain name, run `uc dns reserve`
|
||||
* To release a domain name, run `uc dns release`.
|
||||
* To see the domain name, run `uc dns show`
|
||||
|
||||
### Running a service
|
||||
|
||||
Services on an Uncloud cluster can be managed with `uc service`.
|
||||
|
||||
You can run a service with two replicas that expose port 80 like the following:
|
||||
|
||||
```
|
||||
uc service run -p 80/http --replicas 2 nginxdemos/hello
|
||||
```
|
||||
|
||||
This requires the Caddy reverse proxy to be deployed. If it wasn't previously, it can be deployed with `uc caddy deploy`.
|
||||
|
||||
Since this doesn't specify a service name, a random one will be generated.
|
||||
|
||||
The service can be deleted by its service name:
|
||||
|
||||
```
|
||||
uc service rm hello-gsdo
|
||||
```
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"net/netip"
|
||||
"time"
|
||||
"uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
module uncloud
|
||||
module github.com/psviderski/uncloud
|
||||
|
||||
go 1.22.2
|
||||
|
||||
toolchain go1.22.5
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/caddyserver/caddy/v2 v2.8.4
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/charmbracelet/huh v0.6.0
|
||||
github.com/charmbracelet/lipgloss v0.13.0
|
||||
github.com/compose-spec/compose-go/v2 v2.4.5
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||
github.com/deckarep/golang-set/v2 v2.8.0
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||
github.com/docker/docker v27.3.0+incompatible
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli v27.5.0+incompatible
|
||||
github.com/docker/compose/v2 v2.31.0
|
||||
github.com/docker/docker v27.4.0-rc.2+incompatible
|
||||
github.com/docker/go-connections v0.5.0
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/goccy/go-yaml v1.17.1
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/go-containerregistry v0.20.2
|
||||
github.com/hashicorp/memberlist v0.5.1
|
||||
github.com/hashicorp/serf v0.10.1
|
||||
github.com/ipfs/boxo v0.21.0
|
||||
@@ -20,65 +32,116 @@ require (
|
||||
github.com/ipfs/go-ds-crdt v0.5.2
|
||||
github.com/ipfs/go-ipld-format v0.6.0
|
||||
github.com/ipfs/go-log/v2 v2.5.1
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lmittmann/tint v1.0.5
|
||||
github.com/miekg/dns v1.1.65
|
||||
github.com/moby/term v0.5.0
|
||||
github.com/opencontainers/go-digest v1.0.0
|
||||
github.com/opencontainers/image-spec v1.1.0
|
||||
github.com/siderolabs/discovery-api v0.1.4
|
||||
github.com/siderolabs/discovery-client v0.1.9
|
||||
github.com/siderolabs/grpc-proxy v0.5.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/vishvananda/netlink v1.3.0
|
||||
go.uber.org/zap v1.27.0
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||
golang.org/x/crypto v0.27.0
|
||||
golang.org/x/sync v0.8.0
|
||||
golang.org/x/sys v0.25.0
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/sync v0.11.0
|
||||
golang.org/x/sys v0.31.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||
google.golang.org/grpc v1.66.0
|
||||
google.golang.org/protobuf v1.34.2
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
|
||||
google.golang.org/grpc v1.68.1
|
||||
google.golang.org/protobuf v1.36.3
|
||||
modernc.org/sqlite v1.36.3
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
|
||||
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/OneOfOne/xxhash v1.2.8 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/armon/go-radix v1.0.0 // indirect
|
||||
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bgentry/speakeasy v0.2.0 // indirect
|
||||
github.com/buger/goterm v1.0.4 // indirect
|
||||
github.com/caddyserver/certmagic v0.21.4 // indirect
|
||||
github.com/caddyserver/zerossl v0.1.3 // indirect
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.20.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.1.1 // indirect
|
||||
github.com/charmbracelet/lipgloss v0.13.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.3.2 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||
github.com/chzyer/readline v1.5.1 // indirect
|
||||
github.com/cloudflare/cfssl v1.6.4 // indirect
|
||||
github.com/containerd/console v1.0.4 // indirect
|
||||
github.com/containerd/containerd v1.7.24 // indirect
|
||||
github.com/containerd/containerd/api v1.7.19 // indirect
|
||||
github.com/containerd/continuity v0.4.4 // indirect
|
||||
github.com/containerd/errdefs v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||
github.com/containerd/ttrpc v1.2.5 // indirect
|
||||
github.com/containerd/typeurl/v2 v2.2.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||
github.com/dgraph-io/badger v1.6.2 // indirect
|
||||
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
||||
github.com/docker/buildx v0.18.0 // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/docker/go-metrics v0.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fatih/color v1.17.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fvbommel/sortorder v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
||||
github.com/go-kit/kit v0.13.0 // indirect
|
||||
github.com/go-kit/log v0.2.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/glog v1.2.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
github.com/google/cel-go v0.20.1 // indirect
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
||||
@@ -86,12 +149,14 @@ require (
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.6 // indirect
|
||||
github.com/hashicorp/go-syslog v1.0.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/logutils v1.0.0 // indirect
|
||||
github.com/hashicorp/mdns v1.0.5 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/imdario/mergo v0.3.16 // indirect
|
||||
github.com/in-toto/in-toto-golang v0.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||
github.com/ipfs/go-block-format v0.2.0 // indirect
|
||||
@@ -101,31 +166,54 @@ require (
|
||||
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
|
||||
github.com/ipld/go-ipld-prime v0.21.0 // indirect
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgconn v1.14.3 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgtype v1.14.0 // indirect
|
||||
github.com/jackc/pgx/v4 v4.18.3 // indirect
|
||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/libdns/libdns v0.2.2 // indirect
|
||||
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
|
||||
github.com/libp2p/go-libp2p v0.35.4 // indirect
|
||||
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
|
||||
github.com/libp2p/go-msgio v0.3.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/manifoldco/promptui v0.9.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-shellwords v1.0.12 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/miekg/dns v1.1.62 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/mholt/acmez/v2 v2.0.3 // indirect
|
||||
github.com/miekg/pkcs11 v1.1.1 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mitchellh/cli v1.1.5 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/buildkit v0.17.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/term v0.5.0 // indirect
|
||||
github.com/moby/locker v1.0.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/signal v0.7.1 // indirect
|
||||
github.com/moby/sys/user v0.3.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
@@ -140,37 +228,94 @@ require (
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-multistream v0.5.0 // indirect
|
||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pires/go-proxyproto v0.7.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/polydawn/refmt v0.89.0 // indirect
|
||||
github.com/posener/complete v1.2.3 // indirect
|
||||
github.com/prometheus/client_golang v1.20.5 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.61.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/siderolabs/gen v0.4.8 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/slackhq/nebula v1.6.1 // indirect
|
||||
github.com/smallstep/certificates v0.26.1 // indirect
|
||||
github.com/smallstep/nosql v0.6.1 // indirect
|
||||
github.com/smallstep/pkcs7 v0.0.0-20231024181729-3b98ecc1ca81 // indirect
|
||||
github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
|
||||
github.com/smallstep/truststore v0.13.0 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
|
||||
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c // indirect
|
||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
|
||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
||||
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
|
||||
github.com/urfave/cli v1.22.16 // indirect
|
||||
github.com/vbatts/tar-split v0.11.6 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.etcd.io/bbolt v1.3.11 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect
|
||||
go.opentelemetry.io/otel v1.30.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.30.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.30.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/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
|
||||
go.opentelemetry.io/otel v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.33.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
||||
go.step.sm/cli-utils v0.9.0 // indirect
|
||||
go.step.sm/crypto v0.45.0 // indirect
|
||||
go.step.sm/linkedca v0.20.1 // indirect
|
||||
go.uber.org/automaxprocs v1.5.3 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/mod v0.21.0 // indirect
|
||||
golang.org/x/net v0.29.0 // indirect
|
||||
golang.org/x/text v0.18.0 // indirect
|
||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect
|
||||
golang.org/x/tools v0.25.0 // indirect
|
||||
go.uber.org/zap/exp v0.2.0 // 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/mod v0.23.0 // indirect
|
||||
golang.org/x/term v0.29.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/time v0.8.0 // indirect
|
||||
golang.org/x/tools v0.30.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect
|
||||
gotest.tools/v3 v3.5.1 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||
howett.net/plist v1.0.0 // indirect
|
||||
lukechampine.com/blake3 v1.3.0 // indirect
|
||||
modernc.org/libc v1.61.13 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/cli/cli/config"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/archive"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
"github.com/docker/docker/registry"
|
||||
"github.com/moby/term"
|
||||
)
|
||||
|
||||
type BuildOptions struct {
|
||||
Files []string
|
||||
Profiles []string
|
||||
Services []string
|
||||
Push bool
|
||||
NoCache bool
|
||||
}
|
||||
|
||||
// GetServicesThatNeedBuild returns a map of services that require building
|
||||
func GetServicesThatNeedBuild(project *composetypes.Project) map[string]composetypes.ServiceConfig {
|
||||
servicesToBuild := make(map[string]composetypes.ServiceConfig, len(project.Services))
|
||||
for serviceName, service := range project.Services {
|
||||
if service.Build == nil {
|
||||
continue
|
||||
}
|
||||
servicesToBuild[serviceName] = service
|
||||
}
|
||||
return servicesToBuild
|
||||
}
|
||||
|
||||
// BuildServices builds the services defined in the provided map.
|
||||
func BuildServices(ctx context.Context, servicesToBuild map[string]composetypes.ServiceConfig, opts BuildOptions) error {
|
||||
fmt.Println("Building services...")
|
||||
|
||||
// Init docker client (can be local or remote, depending on DOCKER_HOST environment variable)
|
||||
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dockerCli.Close()
|
||||
|
||||
serviceImages := make(map[string]string, len(servicesToBuild))
|
||||
|
||||
// Build the services using the local docker client and compose libraries
|
||||
for _, service := range servicesToBuild {
|
||||
fmt.Printf("Building service: %s\n", service.Name)
|
||||
imageName, err := buildSingleService(ctx, dockerCli, service, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build service %s: %w", service.Name, err)
|
||||
}
|
||||
serviceImages[service.Name] = imageName
|
||||
}
|
||||
fmt.Printf("Service images are built.\n")
|
||||
|
||||
if opts.Push {
|
||||
err = pushServiceImages(ctx, dockerCli, serviceImages)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// buildSingleService builds a single service using the Docker client and Compose libraries.
|
||||
func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, service composetypes.ServiceConfig, opts BuildOptions) (string, error) {
|
||||
if service.Build == nil {
|
||||
return "", fmt.Errorf("service %s has no build configuration", service.Name)
|
||||
}
|
||||
if service.Image == "" {
|
||||
return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet", service.Name)
|
||||
}
|
||||
|
||||
buildContextPath := service.Build.Context
|
||||
imageName := service.Image
|
||||
|
||||
// Create a tar archive of the build context
|
||||
buildContext, err := archive.TarWithOptions(buildContextPath, &archive.TarOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create build context for service %s: %w", service.Name, err)
|
||||
}
|
||||
|
||||
buildOptions := types.ImageBuildOptions{
|
||||
// TODO: Support Dockerfiles outside the build context
|
||||
// See https://github.com/docker/compose/blob/cf89fd1aa1328d5af77658ccc5a1e1b29981ae80/pkg/compose/build_classic.go#L92
|
||||
Dockerfile: service.Build.Dockerfile,
|
||||
Tags: []string{imageName},
|
||||
Remove: true, // Remove intermediate containers
|
||||
NoCache: opts.NoCache,
|
||||
}
|
||||
|
||||
buildResponse, err := dockerCli.ImageBuild(ctx, buildContext, buildOptions)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build image for service %s: %w", service.Name, err)
|
||||
}
|
||||
defer buildResponse.Body.Close()
|
||||
|
||||
// Display the build response
|
||||
fd, isTerminal := term.GetFdInfo(os.Stdout)
|
||||
if err := jsonmessage.DisplayJSONMessagesStream(buildResponse.Body, os.Stdout, fd, isTerminal, nil); err != nil {
|
||||
return "", fmt.Errorf("failed to display build response for service %s: %w", service.Name, err)
|
||||
}
|
||||
|
||||
return imageName, nil
|
||||
}
|
||||
|
||||
// pushSingleServiceImage pushes a single service image.
|
||||
func pushSingleServiceImage(ctx context.Context, dockerCli *dockerclient.Client, serviceName string, imageName string) error {
|
||||
ref, err := reference.ParseNormalizedNamed(imageName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoInfo, err := registry.ParseRepositoryInfo(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registryKey := repoInfo.Index.Name
|
||||
if repoInfo.Index.Official {
|
||||
registryKey = registry.IndexServer
|
||||
}
|
||||
|
||||
// Load the Docker config file with auth details, if available
|
||||
configFile := config.LoadDefaultConfigFile(os.Stderr)
|
||||
|
||||
authConfig, err := configFile.GetAuthConfig(registryKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authJSON, err := json.Marshal(authConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal auth config for registry %s: %w", registryKey, err)
|
||||
}
|
||||
|
||||
authStr := base64.URLEncoding.EncodeToString(authJSON)
|
||||
|
||||
pushOptions := image.PushOptions{
|
||||
RegistryAuth: authStr,
|
||||
}
|
||||
pushResponse, err := dockerCli.ImagePush(ctx, imageName, pushOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to push image %s: %w", imageName, err)
|
||||
}
|
||||
defer pushResponse.Close()
|
||||
|
||||
fmt.Printf("Pushing image %s for service %s...\n", imageName, serviceName)
|
||||
|
||||
fd, isTerminal := term.GetFdInfo(os.Stdout)
|
||||
if err := jsonmessage.DisplayJSONMessagesStream(pushResponse, os.Stdout, fd, isTerminal, nil); err != nil {
|
||||
return fmt.Errorf("failed to display push response for image %s: %w", imageName, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Image %s pushed successfully.\n", imageName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushServiceImages pushes all built service images to the registry.
|
||||
func pushServiceImages(ctx context.Context, dockerCli *dockerclient.Client, serviceImages map[string]string) error {
|
||||
fmt.Printf("Pushing images...\n")
|
||||
for serviceName, imageName := range serviceImages {
|
||||
if err := pushSingleServiceImage(ctx, dockerCli, serviceName, imageName); err != nil {
|
||||
return fmt.Errorf("push image for service %s: %w", serviceName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+277
-207
@@ -4,262 +4,313 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/charmbracelet/huh"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"uncloud/internal/cli/client"
|
||||
"uncloud/internal/cli/client/connector"
|
||||
"uncloud/internal/cli/config"
|
||||
"uncloud/internal/machine"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
"uncloud/internal/secret"
|
||||
"uncloud/internal/sshexec"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"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/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/sshexec"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
const defaultClusterName = "default"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
const defaultContextName = "default"
|
||||
|
||||
type CLI struct {
|
||||
config *config.Config
|
||||
Config *config.Config
|
||||
conn *config.MachineConnection
|
||||
}
|
||||
|
||||
// New creates a new CLI instance with the given config path or remote machine connection.
|
||||
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
|
||||
// a cluster without creating a config.
|
||||
func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
|
||||
if conn != nil {
|
||||
return &CLI{conn: conn}, nil
|
||||
}
|
||||
|
||||
func New(configPath string) (*CLI, error) {
|
||||
cfg, err := config.NewFromFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Uncloud config: %w", err)
|
||||
}
|
||||
|
||||
return &CLI{
|
||||
config: cfg,
|
||||
Config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cli *CLI) CreateCluster(name string, userPrivateKey secret.Secret) error {
|
||||
if _, ok := cli.config.Clusters[name]; ok {
|
||||
return fmt.Errorf("cluster %q already exists", name)
|
||||
func (cli *CLI) CreateContext(name string) error {
|
||||
if _, ok := cli.Config.Contexts[name]; ok {
|
||||
return fmt.Errorf("context '%s' already exists", name)
|
||||
}
|
||||
if userPrivateKey == nil {
|
||||
user, err := client.NewUser(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate user: %w", err)
|
||||
}
|
||||
userPrivateKey = user.PrivateKey()
|
||||
}
|
||||
|
||||
cli.config.Clusters[name] = &config.Cluster{
|
||||
cli.Config.Contexts[name] = &config.Context{
|
||||
Name: name,
|
||||
UserPrivateKey: userPrivateKey,
|
||||
}
|
||||
return cli.config.Save()
|
||||
return cli.Config.Save()
|
||||
}
|
||||
|
||||
func (cli *CLI) CreateDefaultCluster() error {
|
||||
if err := cli.CreateCluster(defaultClusterName, nil); err != nil {
|
||||
return err
|
||||
func (cli *CLI) SetCurrentContext(name string) error {
|
||||
if _, ok := cli.Config.Contexts[name]; !ok {
|
||||
return api.ErrNotFound
|
||||
}
|
||||
return cli.SetCurrentCluster(defaultClusterName)
|
||||
cli.Config.CurrentContext = name
|
||||
return cli.Config.Save()
|
||||
}
|
||||
|
||||
func (cli *CLI) SetCurrentCluster(name string) error {
|
||||
if _, ok := cli.config.Clusters[name]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
cli.config.CurrentCluster = name
|
||||
return cli.config.Save()
|
||||
// ConnectCluster connects to a cluster using the given context name 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, contextName string) (*client.Client, error) {
|
||||
if cli.conn != nil {
|
||||
return connectCluster(ctx, *cli.conn)
|
||||
}
|
||||
|
||||
func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client.Client, error) {
|
||||
if len(cli.config.Clusters) == 0 {
|
||||
return nil, errors.New(
|
||||
"no clusters found in the Uncloud config. " +
|
||||
"Please initialise a cluster with `uncloud machine init` first",
|
||||
)
|
||||
}
|
||||
if clusterName == "" {
|
||||
// If the cluster is not specified, use the current cluster if set.
|
||||
if cli.config.CurrentCluster == "" {
|
||||
return nil, errors.New(
|
||||
"the current cluster is not set in the Uncloud config. " +
|
||||
"Please specify a cluster with the --cluster flag or set current_cluster in the config",
|
||||
)
|
||||
}
|
||||
if _, ok := cli.config.Clusters[cli.config.CurrentCluster]; !ok {
|
||||
if len(cli.Config.Contexts) == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"current cluster %q not found in the config. "+
|
||||
"Please specify a cluster with the --cluster flag or update current_cluster in the config",
|
||||
cli.config.CurrentCluster,
|
||||
"no cluster contexts found in the Uncloud config (%s). "+
|
||||
"Please initialise a cluster with 'uncloud machine init' first",
|
||||
cli.Config.Path(),
|
||||
)
|
||||
}
|
||||
clusterName = cli.config.CurrentCluster
|
||||
if contextName == "" {
|
||||
// If the cluster is not specified, use the current cluster if set.
|
||||
if cli.Config.CurrentContext == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"the current cluster context is not set in the Uncloud config (%s). "+
|
||||
"Please specify the context with the '--context' flag or set 'current_context' in the config",
|
||||
cli.Config.Path(),
|
||||
)
|
||||
}
|
||||
if _, ok := cli.Config.Contexts[cli.Config.CurrentContext]; !ok {
|
||||
return nil, fmt.Errorf(
|
||||
"current cluster context '%s' not found in the Uncloud config (%s). "+
|
||||
"Please specify the context with the '--context' flag or update 'current_context' in the config",
|
||||
cli.Config.CurrentContext,
|
||||
cli.Config.Path(),
|
||||
)
|
||||
}
|
||||
contextName = cli.Config.CurrentContext
|
||||
}
|
||||
|
||||
cfg, ok := cli.config.Clusters[clusterName]
|
||||
cfg, ok := cli.Config.Contexts[contextName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cluster %q not found in the config", clusterName)
|
||||
return nil, fmt.Errorf("cluster context '%s' not found in the Uncloud config (%s)",
|
||||
contextName, cli.Config.Path())
|
||||
}
|
||||
if len(cfg.Connections) == 0 {
|
||||
return nil, fmt.Errorf("no connection configurations found for cluster %q in the config", clusterName)
|
||||
return nil, fmt.Errorf(
|
||||
"no connection configurations found for cluster context '%s' in the Uncloud config (%s)",
|
||||
contextName, cli.Config.Path(),
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
|
||||
conn := cfg.Connections[0]
|
||||
|
||||
c, err := connectCluster(ctx, conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func (cli *CLI) InitCluster(
|
||||
ctx context.Context, remoteMachine *RemoteMachine, clusterName, machineName string, netPrefix netip.Prefix,
|
||||
) error {
|
||||
if remoteMachine != nil {
|
||||
return cli.initRemoteMachine(ctx, *remoteMachine, clusterName, machineName, netPrefix)
|
||||
return nil, errors.New("connection configuration is invalid")
|
||||
}
|
||||
|
||||
type InitClusterOptions struct {
|
||||
Context string
|
||||
MachineName string
|
||||
Network netip.Prefix
|
||||
PublicIP *netip.Addr
|
||||
RemoteMachine *RemoteMachine
|
||||
Version string
|
||||
}
|
||||
|
||||
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
|
||||
// The client should be closed after use by the caller.
|
||||
func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||
if opts.RemoteMachine != nil {
|
||||
return cli.initRemoteMachine(ctx, opts)
|
||||
}
|
||||
// TODO: implement local machine initialisation
|
||||
return fmt.Errorf("local machine initialisation is not implemented yet")
|
||||
return nil, fmt.Errorf("local machine initialisation is not implemented yet")
|
||||
}
|
||||
|
||||
func (cli *CLI) initRemoteMachine(
|
||||
ctx context.Context, remoteMachine RemoteMachine, clusterName, machineName string, netPrefix netip.Prefix,
|
||||
) error {
|
||||
if clusterName == "" {
|
||||
clusterName = defaultClusterName
|
||||
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||
contextName := opts.Context
|
||||
if contextName == "" {
|
||||
contextName = defaultContextName
|
||||
}
|
||||
if _, ok := cli.config.Clusters[clusterName]; ok {
|
||||
return fmt.Errorf("cluster %q already exists", clusterName)
|
||||
}
|
||||
user, err := client.NewUser(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate cluster user: %w", err)
|
||||
if _, ok := cli.Config.Contexts[contextName]; ok {
|
||||
return nil, fmt.Errorf("cluster context '%s' already exists", contextName)
|
||||
}
|
||||
|
||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
||||
machineClient, err := cli.provisionRemoteMachine(ctx, *opts.RemoteMachine, opts.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"SSH login to remote machine %s: %w",
|
||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
exec := sshexec.NewRemote(sshClient)
|
||||
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
||||
if err = provisionMachine(ctx, exec); err != nil {
|
||||
return fmt.Errorf("provision machine: %w", err)
|
||||
}
|
||||
|
||||
// Create a machine API client over the SSH connection to the remote machine.
|
||||
machineClient, err := client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
||||
// Ensure machineClient is closed on error.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to remote machine: %w", err)
|
||||
machineClient.Close()
|
||||
}
|
||||
defer machineClient.Close()
|
||||
}()
|
||||
|
||||
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
||||
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect machine: %w", err)
|
||||
return nil, fmt.Errorf("inspect machine: %w", err)
|
||||
}
|
||||
if minfo.Id != "" {
|
||||
if err = cli.promptResetMachine(); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Check machine meets all necessary system requirements before proceeding.
|
||||
checkResp, err := machineClient.CheckPrerequisites(ctx, &emptypb.Empty{})
|
||||
// TODO(lhf): remove Unimplemented check when v0.9.0 is released.
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() != codes.Unimplemented {
|
||||
return nil, fmt.Errorf("check machine prerequisites: %w", err)
|
||||
}
|
||||
} else if !checkResp.Satisfied {
|
||||
return nil, fmt.Errorf("machine prerequisites not satisfied: %s", checkResp.Error)
|
||||
}
|
||||
|
||||
req := &pb.InitClusterRequest{
|
||||
MachineName: machineName,
|
||||
Network: pb.NewIPPrefix(netPrefix),
|
||||
User: &pb.User{
|
||||
Network: &pb.NetworkConfig{
|
||||
ManagementIp: pb.NewIP(user.ManagementIP()),
|
||||
PublicKey: user.PublicKey(),
|
||||
},
|
||||
},
|
||||
MachineName: opts.MachineName,
|
||||
Network: pb.NewIPPrefix(opts.Network),
|
||||
}
|
||||
if opts.PublicIP != nil {
|
||||
if opts.PublicIP.IsValid() {
|
||||
req.PublicIpConfig = &pb.InitClusterRequest_PublicIp{PublicIp: pb.NewIP(*opts.PublicIP)}
|
||||
} else {
|
||||
// Invalid or in other words zero IP means to automatically detect the public IP.
|
||||
req.PublicIpConfig = &pb.InitClusterRequest_PublicIpAuto{PublicIpAuto: true}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := machineClient.InitCluster(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init cluster: %w", err)
|
||||
return nil, fmt.Errorf("init cluster: %w", err)
|
||||
}
|
||||
fmt.Printf("Cluster %q initialised with machine %q\n", clusterName, resp.Machine.Name)
|
||||
fmt.Printf("Cluster initialised with machine '%s' and saved as context '%s' in your local config (%s)\n",
|
||||
resp.Machine.Name, contextName, cli.Config.Path())
|
||||
if err = cli.CreateContext(contextName); err != nil {
|
||||
return nil, fmt.Errorf("save cluster context to config: %w", err)
|
||||
}
|
||||
if err = cli.SetCurrentContext(contextName); err != nil {
|
||||
return nil, fmt.Errorf("set current cluster context: %w", err)
|
||||
}
|
||||
fmt.Printf("Current cluster context is now '%s'.\n", contextName)
|
||||
|
||||
if err = cli.CreateCluster(clusterName, user.PrivateKey()); err != nil {
|
||||
return fmt.Errorf("save cluster to config: %w", err)
|
||||
}
|
||||
// Set the current cluster to the just created one if it is the only cluster in the config.
|
||||
if len(cli.config.Clusters) == 1 {
|
||||
if err = cli.SetCurrentCluster(clusterName); err != nil {
|
||||
return fmt.Errorf("set current cluster: %w", err)
|
||||
}
|
||||
}
|
||||
// Save the machine's SSH connection details in the cluster config.
|
||||
// Save the machine's SSH connection details in the context config.
|
||||
connCfg := config.MachineConnection{
|
||||
SSH: config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port),
|
||||
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||
}
|
||||
cli.config.Clusters[clusterName].Connections = append(cli.config.Clusters[clusterName].Connections, connCfg)
|
||||
if err = cli.config.Save(); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||
if err = cli.Config.Save(); err != nil {
|
||||
return nil, fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
return machineClient, nil
|
||||
}
|
||||
|
||||
func (cli *CLI) AddMachine(ctx context.Context, remoteMachine RemoteMachine, clusterName, machineName string) error {
|
||||
c, err := cli.ConnectCluster(ctx, clusterName)
|
||||
type AddMachineOptions struct {
|
||||
Context string
|
||||
MachineName string
|
||||
PublicIP *netip.Addr
|
||||
RemoteMachine RemoteMachine
|
||||
Version string
|
||||
}
|
||||
|
||||
// AddMachine provisions a remote machine and adds it to the cluster. It returns a cluster client and a machine client.
|
||||
// The cluster client is connected to the existing machine in the cluster. It was used to add the new machine to the
|
||||
// cluster. The machine client is connected to the new machine and can be used to interact with it.
|
||||
// Both client should be closed after use by the caller.
|
||||
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) {
|
||||
contextName := opts.Context
|
||||
if contextName == "" {
|
||||
contextName = cli.Config.CurrentContext
|
||||
}
|
||||
c, err := cli.ConnectCluster(ctx, contextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||
}
|
||||
defer func() {
|
||||
_ = c.Close()
|
||||
if err != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
||||
machineClient, err := cli.provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"SSH login to remote machine %s: %w",
|
||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
||||
)
|
||||
return nil, nil, err
|
||||
}
|
||||
exec := sshexec.NewRemote(sshClient)
|
||||
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
||||
if err = provisionMachine(ctx, exec); err != nil {
|
||||
return fmt.Errorf("provision machine: %w", err)
|
||||
}
|
||||
|
||||
// Create a machine API client over the SSH connection to the remote machine.
|
||||
machineClient, err := client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
||||
defer func() {
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to remote machine: %w", err)
|
||||
machineClient.Close()
|
||||
}
|
||||
defer machineClient.Close()
|
||||
}()
|
||||
|
||||
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
||||
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect machine: %w", err)
|
||||
return nil, nil, fmt.Errorf("inspect machine: %w", err)
|
||||
}
|
||||
if minfo.Id != "" {
|
||||
if err = cli.promptResetMachine(); err != nil {
|
||||
return err
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Check machine meets all necessary system requirements before proceeding.
|
||||
checkResp, err := machineClient.CheckPrerequisites(ctx, &emptypb.Empty{})
|
||||
// TODO(lhf): remove Unimplemented check when v0.9.0 is released.
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() != codes.Unimplemented {
|
||||
return nil, nil, fmt.Errorf("check machine prerequisites: %w", err)
|
||||
}
|
||||
} else if !checkResp.Satisfied {
|
||||
return nil, nil, fmt.Errorf("machine prerequisites not satisfied: %s", checkResp.Error)
|
||||
}
|
||||
|
||||
tokenResp, err := machineClient.Token(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get remote machine token: %w", err)
|
||||
return nil, nil, fmt.Errorf("get remote machine token: %w", err)
|
||||
}
|
||||
token, err := machine.ParseToken(tokenResp.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse remote machine token: %w", err)
|
||||
return nil, nil, fmt.Errorf("parse remote machine token: %w", err)
|
||||
}
|
||||
|
||||
// Register the machine in the cluster using its public key and endpoints from the token.
|
||||
@@ -268,26 +319,35 @@ func (cli *CLI) AddMachine(ctx context.Context, remoteMachine RemoteMachine, clu
|
||||
endpoints[i] = pb.NewIPPort(addrPort)
|
||||
}
|
||||
addReq := &pb.AddMachineRequest{
|
||||
Name: machineName,
|
||||
Name: opts.MachineName,
|
||||
Network: &pb.NetworkConfig{
|
||||
Endpoints: endpoints,
|
||||
PublicKey: token.PublicKey,
|
||||
},
|
||||
}
|
||||
if opts.PublicIP != nil {
|
||||
if opts.PublicIP.IsValid() {
|
||||
addReq.PublicIp = pb.NewIP(*opts.PublicIP)
|
||||
} else if token.PublicIP.IsValid() {
|
||||
// Invalid or in other words zero IP means to use an automatically detected public IP from the token.
|
||||
addReq.PublicIp = pb.NewIP(token.PublicIP)
|
||||
}
|
||||
}
|
||||
|
||||
addResp, err := c.AddMachine(ctx, addReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add machine to cluster: %w", 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.
|
||||
listResp, err := c.ListMachines(ctx, &emptypb.Empty{})
|
||||
machines, err := c.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list cluster machines: %w", err)
|
||||
return nil, nil, fmt.Errorf("list cluster machines: %w", err)
|
||||
}
|
||||
otherMachines := make([]*pb.MachineInfo, 0, len(listResp.Machines)-1)
|
||||
for _, m := range listResp.Machines {
|
||||
if m.Id != addResp.Machine.Id {
|
||||
otherMachines = append(otherMachines, m)
|
||||
otherMachines := make([]*pb.MachineInfo, 0, len(machines)-1)
|
||||
for _, m := range machines {
|
||||
if m.Machine.Id != addResp.Machine.Id {
|
||||
otherMachines = append(otherMachines, m.Machine)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,24 +357,67 @@ func (cli *CLI) AddMachine(ctx context.Context, remoteMachine RemoteMachine, clu
|
||||
OtherMachines: otherMachines,
|
||||
}
|
||||
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
|
||||
return fmt.Errorf("join cluster: %w", err)
|
||||
return nil, nil, fmt.Errorf("join cluster: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Machine %q added to cluster\n", addResp.Machine.Name)
|
||||
// TODO: fix empty context name when using the current context (contextName == "").
|
||||
fmt.Printf("Machine '%s' added to the cluster (context '%s').\n", addResp.Machine.Name, contextName)
|
||||
|
||||
// Save the machine's SSH connection details in the cluster config.
|
||||
// Save the machine's SSH connection details in the context config.
|
||||
connCfg := config.MachineConnection{
|
||||
SSH: config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port),
|
||||
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||
}
|
||||
if clusterName == "" {
|
||||
clusterName = cli.config.CurrentCluster
|
||||
if contextName == "" {
|
||||
contextName = cli.Config.CurrentContext
|
||||
}
|
||||
cli.config.Clusters[clusterName].Connections = append(cli.config.Clusters[clusterName].Connections, connCfg)
|
||||
if err = cli.config.Save(); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||
if err = cli.Config.Save(); err != nil {
|
||||
return nil, nil, fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return c, machineClient, nil
|
||||
}
|
||||
|
||||
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and 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.
|
||||
func (cli *CLI) provisionRemoteMachine(
|
||||
ctx context.Context, remoteMachine RemoteMachine, version string,
|
||||
) (*client.Client, error) {
|
||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"SSH login to remote machine %s: %w",
|
||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
||||
)
|
||||
}
|
||||
exec := sshexec.NewRemote(sshClient)
|
||||
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
||||
if err = provisionMachine(ctx, exec, version); err != nil {
|
||||
return nil, fmt.Errorf("provision machine: %w", err)
|
||||
}
|
||||
|
||||
var machineClient *client.Client
|
||||
if remoteMachine.User == "root" {
|
||||
// Create a machine API client over the established SSH connection to the remote machine.
|
||||
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
||||
} else {
|
||||
// Since the user is not root, we need to establish a new SSH connection to make the user's addition
|
||||
// to the uncloud group effective, thus allowing access to the Uncloud daemon Unix socket.
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: remoteMachine.User,
|
||||
Host: remoteMachine.Host,
|
||||
Port: remoteMachine.Port,
|
||||
KeyPath: remoteMachine.KeyPath,
|
||||
}
|
||||
machineClient, err = client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to remote machine: %w", err)
|
||||
}
|
||||
return machineClient, nil
|
||||
}
|
||||
|
||||
func (cli *CLI) promptResetMachine() error {
|
||||
@@ -329,7 +432,7 @@ func (cli *CLI) promptResetMachine() error {
|
||||
Negative("No").
|
||||
Value(&confirm),
|
||||
),
|
||||
)
|
||||
).WithAccessible(true)
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("prompt user to confirm: %w", err)
|
||||
}
|
||||
@@ -338,44 +441,11 @@ func (cli *CLI) promptResetMachine() error {
|
||||
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")
|
||||
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")
|
||||
}
|
||||
|
||||
func (cli *CLI) ListMachines(ctx context.Context, clusterName string) error {
|
||||
c, err := cli.ConnectCluster(ctx, clusterName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = c.Close()
|
||||
}()
|
||||
|
||||
listResp, err := c.ListMachines(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
|
||||
// Print the list of machines in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
// Print header.
|
||||
// TODO: print ADDRESS instead of SUBNET which is a machine IP with prefix.
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tSUBNET\tPUBLIC KEY\tENDPOINTS"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
// Print rows.
|
||||
for _, m := range listResp.Machines {
|
||||
subnet, _ := m.Network.Subnet.ToPrefix()
|
||||
endpoints := make([]string, len(m.Network.Endpoints))
|
||||
for i, ep := range m.Network.Endpoints {
|
||||
addrPort, _ := ep.ToAddrPort()
|
||||
endpoints[i] = addrPort.String()
|
||||
}
|
||||
publicKey := secret.Secret(m.Network.PublicKey)
|
||||
if _, err = fmt.Fprintf(
|
||||
tw, "%s\t%s\t%s\t%s\n", m.Name, subnet, publicKey, strings.Join(endpoints, ", "),
|
||||
); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
// ProgressOut returns an output stream for progress writer.
|
||||
func (cli *CLI) ProgressOut() *streams.Out {
|
||||
return streams.NewOut(os.Stdout)
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"google.golang.org/grpc"
|
||||
"uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
// Client is a client for the machine API.
|
||||
type Client struct {
|
||||
connector Connector
|
||||
conn *grpc.ClientConn
|
||||
|
||||
pb.MachineClient
|
||||
pb.ClusterClient
|
||||
}
|
||||
|
||||
// Connector is an interface for establishing a connection to the machine API.
|
||||
type Connector interface {
|
||||
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// New creates a new client for the machine API. The connector is used to establish the connection
|
||||
// either locally or remotely. The client is responsible for closing the connector.
|
||||
func New(ctx context.Context, connector Connector) (*Client, error) {
|
||||
c := &Client{
|
||||
connector: connector,
|
||||
}
|
||||
var err error
|
||||
c.conn, err = connector.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to machine: %w", err)
|
||||
}
|
||||
|
||||
c.MachineClient = pb.NewMachineClient(c.conn)
|
||||
c.ClusterClient = pb.NewClusterClient(c.conn)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
err := c.conn.Close()
|
||||
return errors.Join(err, c.connector.Close())
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package config
|
||||
|
||||
import "uncloud/internal/secret"
|
||||
|
||||
type Cluster struct {
|
||||
Name string `toml:"-"`
|
||||
Connections []MachineConnection `toml:"connections"`
|
||||
// UserPrivateKey is the user's WireGuard private key used to connect to cluster machines.
|
||||
UserPrivateKey secret.Secret `toml:"user_private_key"`
|
||||
}
|
||||
@@ -2,14 +2,15 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/BurntSushi/toml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Clusters map[string]*Cluster `toml:"clusters"`
|
||||
CurrentCluster string `toml:"current_cluster"`
|
||||
CurrentContext string `yaml:"current_context"`
|
||||
Contexts map[string]*Context `yaml:"contexts"`
|
||||
|
||||
// path is the file path config is read from.
|
||||
path string
|
||||
@@ -18,10 +19,10 @@ type Config struct {
|
||||
func NewFromFile(path string) (*Config, error) {
|
||||
_, err := os.Stat(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("check file permissions %q: %w", path, err)
|
||||
return nil, fmt.Errorf("check file permissions '%s': %w", path, err)
|
||||
}
|
||||
c := &Config{
|
||||
Clusters: map[string]*Cluster{},
|
||||
Contexts: map[string]*Context{},
|
||||
path: path,
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
@@ -34,30 +35,37 @@ func NewFromFile(path string) (*Config, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Config) Read() error {
|
||||
_, err := toml.DecodeFile(c.path, c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config file %q: %w", c.path, err)
|
||||
func (c *Config) Path() string {
|
||||
return c.path
|
||||
}
|
||||
|
||||
func (c *Config) Read() error {
|
||||
data, err := os.ReadFile(c.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config file '%s': %w", c.path, err)
|
||||
}
|
||||
if err = yaml.Unmarshal(data, c); err != nil {
|
||||
return fmt.Errorf("parse config file '%s': %s", c.path, yaml.FormatError(err, true, true))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
dir, _ := filepath.Split(c.path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return fmt.Errorf("create config directory %q: %w", dir, err)
|
||||
return fmt.Errorf("create config directory '%s': %w", dir, err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(c.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write config file %q: %w", c.path, err)
|
||||
return fmt.Errorf("write config file '%s': %w", c.path, err)
|
||||
}
|
||||
|
||||
encoder := toml.NewEncoder(f)
|
||||
encoder.Indent = ""
|
||||
encoder := yaml.NewEncoder(f, yaml.Indent(2), yaml.IndentSequence(true))
|
||||
if err = encoder.Encode(c); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("encode config file %q: %w", c.path, err)
|
||||
return fmt.Errorf("encode config file '%s': %w", c.path, err)
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package config
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"uncloud/internal/secret"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -13,9 +15,13 @@ const (
|
||||
)
|
||||
|
||||
type MachineConnection struct {
|
||||
SSH SSHDestination `toml:"ssh,omitempty"`
|
||||
Host string `toml:"host,omitempty"`
|
||||
PublicKey secret.Secret `toml:"public_key,omitempty"`
|
||||
SSH SSHDestination `yaml:"ssh,omitempty"`
|
||||
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
|
||||
// TCP is the address and port of the machine's API server.
|
||||
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
|
||||
TCP *netip.AddrPort `yaml:"tcp,omitempty"`
|
||||
Host string `yaml:"host,omitempty"`
|
||||
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
||||
}
|
||||
|
||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
type Context struct {
|
||||
Name string `yaml:"-"`
|
||||
Connections []MachineConnection `yaml:"connections"`
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
|
||||
func ExpandCommaSeparatedValues(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var expanded []string
|
||||
for _, value := range values {
|
||||
for _, v := range strings.Split(value, ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
expanded = append(expanded, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expanded
|
||||
}
|
||||
+28
-12
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"uncloud/internal/sshexec"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/sshexec"
|
||||
)
|
||||
|
||||
// TODO: support pinning the script version to the CLI version.
|
||||
@@ -17,24 +19,38 @@ type RemoteMachine struct {
|
||||
KeyPath string
|
||||
}
|
||||
|
||||
func installCmd(user string, version string) string {
|
||||
sudoPrefix := ""
|
||||
var env []string
|
||||
|
||||
// Add the SSH user (non-root) to the uncloud group to allow access to the Uncloud daemon unix socket.
|
||||
if user != "root" {
|
||||
sudoPrefix = "sudo"
|
||||
env = append(env, "UNCLOUD_GROUP_ADD_USER="+sshexec.Quote(user))
|
||||
}
|
||||
if version != "" {
|
||||
env = append(env, "UNCLOUD_VERSION="+sshexec.Quote(version))
|
||||
}
|
||||
|
||||
envCmd := strings.Join(env, " ")
|
||||
curlBashCmd := fmt.Sprintf("curl -fsSL %s | %s %s bash", sshexec.Quote(installScriptURL), sudoPrefix, envCmd)
|
||||
|
||||
return curlBashCmd
|
||||
}
|
||||
|
||||
// provisionMachine provisions the remote machine by downloading the Uncloud install script from GitHub and running it.
|
||||
func provisionMachine(ctx context.Context, exec sshexec.Executor) error {
|
||||
// If version is specified, it will be passed to the install script as UNCLOUD_VERSION environment variable.
|
||||
func provisionMachine(ctx context.Context, exec sshexec.Executor, version string) error {
|
||||
user, err := exec.Run(ctx, "whoami")
|
||||
if err != nil {
|
||||
return fmt.Errorf("run whoami: %w", err)
|
||||
}
|
||||
sudoPrefix, env := "", ""
|
||||
if user != "root" {
|
||||
sudoPrefix = "sudo"
|
||||
// Add the SSH user (non-root) to the uncloud group to allow access to the Uncloud daemon unix socket.
|
||||
env = "UNCLOUD_GROUP_ADD_USER=" + user
|
||||
}
|
||||
|
||||
cmd := installCmd(user, version)
|
||||
|
||||
fmt.Println("Downloading Uncloud install script:", installScriptURL)
|
||||
curlBashCmd := fmt.Sprintf(
|
||||
"curl -fsSL %s | %s %s bash", sshexec.Quote(installScriptURL), sudoPrefix, sshexec.Quote(env),
|
||||
)
|
||||
cmd := sshexec.QuoteCommand("bash", "-c", "set -o pipefail; "+curlBashCmd)
|
||||
|
||||
cmd = sshexec.QuoteCommand("bash", "-c", "set -o pipefail; "+cmd)
|
||||
if err = exec.Stream(ctx, cmd, os.Stdout, os.Stderr); err != nil {
|
||||
return fmt.Errorf("download and run install script: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestInstallCmd(t *testing.T) {
|
||||
t.Run("root", func(t *testing.T) {
|
||||
cmd := installCmd("root", "")
|
||||
assert.NotContains(t, cmd, "sudo")
|
||||
assert.NotContains(t, cmd, "UNCLOUD_GROUP_ADD_USER")
|
||||
})
|
||||
|
||||
// Test with version
|
||||
t.Run("root with version", func(t *testing.T) {
|
||||
cmd := installCmd("root", "v1.2.3")
|
||||
assert.NotContains(t, cmd, "sudo")
|
||||
assert.NotContains(t, cmd, "UNCLOUD_GROUP_ADD_USER")
|
||||
assert.Contains(t, cmd, "UNCLOUD_VERSION=v1.2.3")
|
||||
})
|
||||
|
||||
t.Run("nonroot", func(t *testing.T) {
|
||||
cmd := installCmd("nonroot", "")
|
||||
assert.Contains(t, cmd, "sudo")
|
||||
assert.Contains(t, cmd, "UNCLOUD_GROUP_ADD_USER=nonroot")
|
||||
})
|
||||
|
||||
t.Run("nonroot with version", func(t *testing.T) {
|
||||
cmd := installCmd("nonroot", "v1.2.3")
|
||||
assert.Contains(t, cmd, "sudo")
|
||||
assert.Contains(t, cmd, "UNCLOUD_GROUP_ADD_USER=nonroot")
|
||||
assert.Contains(t, cmd, "UNCLOUD_VERSION=v1.2.3")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cli
|
||||
|
||||
import "github.com/charmbracelet/huh"
|
||||
|
||||
func Confirm() (bool, error) {
|
||||
var confirmed bool
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(
|
||||
"Do you want to continue?",
|
||||
).
|
||||
Affirmative("Yes!").
|
||||
Negative("No").
|
||||
Value(&confirmed),
|
||||
),
|
||||
).WithAccessible(true)
|
||||
if err := form.Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return confirmed, nil
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package corrosion
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AdminClient is a client for the Corrosion admin API.
|
||||
type AdminClient struct {
|
||||
sockPath string
|
||||
}
|
||||
|
||||
func NewAdminClient(sockPath string) (*AdminClient, error) {
|
||||
return &AdminClient{sockPath: sockPath}, nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
JSON map[string]any
|
||||
// Err is set if the response is an error or if an error occurred while processing the response.
|
||||
Err error
|
||||
}
|
||||
|
||||
// SendCommand sends a command to the Corrosion admin API and returns a channel that will receive responses.
|
||||
// The channel will be closed after sending the last or error response. The caller must read from the channel until
|
||||
// it is closed.
|
||||
func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) {
|
||||
conn, err := net.Dial("unix", c.sockPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to admin socket: %w", err)
|
||||
}
|
||||
|
||||
if _, err = conn.Write(encodeFrame(cmd)); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("send command: %w", err)
|
||||
}
|
||||
|
||||
ch := make(chan Response)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
r := Response{}
|
||||
|
||||
data, err := readFrame(conn)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
ch <- r
|
||||
return
|
||||
}
|
||||
|
||||
var decoded any
|
||||
if err = json.Unmarshal(data, &decoded); err != nil {
|
||||
r.Err = fmt.Errorf("unmarshal response: %w", err)
|
||||
ch <- r
|
||||
return
|
||||
}
|
||||
|
||||
switch v := decoded.(type) {
|
||||
case string:
|
||||
if v == "Success" {
|
||||
return
|
||||
}
|
||||
// Ignore other strings.
|
||||
case map[string]any:
|
||||
if errData, ok := v["Error"].(map[string]any); ok {
|
||||
if errMsg, ok := errData["msg"].(string); ok {
|
||||
r.Err = errors.New(errMsg)
|
||||
} else {
|
||||
r.Err = fmt.Errorf("invalid error response: %v", errData)
|
||||
}
|
||||
ch <- r
|
||||
return
|
||||
} else if jsonData, ok := v["Json"].(map[string]any); ok {
|
||||
r.JSON = jsonData
|
||||
ch <- r
|
||||
}
|
||||
// Ignore other maps.
|
||||
default:
|
||||
// Ignore other types.
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// encodeFrame encodes a length_delimited Tokio frame by prefacing frame data with a frame head that specifies
|
||||
// the length of the frame.
|
||||
func encodeFrame(data []byte) []byte {
|
||||
encoded := make([]byte, 4+len(data))
|
||||
binary.BigEndian.PutUint32(encoded, uint32(len(data)))
|
||||
copy(encoded[4:], data)
|
||||
return encoded
|
||||
}
|
||||
|
||||
// readFrame reads a length_delimited Tokio frame from the connection by extracting the frame data that follows
|
||||
// the frame head.
|
||||
func readFrame(conn net.Conn) ([]byte, error) {
|
||||
// Read the frame head (4 bytes).
|
||||
head := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, head); err != nil {
|
||||
return nil, fmt.Errorf("read frame head: %w", err)
|
||||
}
|
||||
// Read the frame data (length specified in the frame head).
|
||||
length := binary.BigEndian.Uint32(head)
|
||||
data := make([]byte, length)
|
||||
if _, err := io.ReadFull(conn, data); err != nil {
|
||||
return nil, fmt.Errorf("read frame data: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type ClusterMembershipState struct {
|
||||
ID string
|
||||
Addr netip.AddrPort
|
||||
State string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
// MembershipStateAlive indicates that the member is active.
|
||||
MembershipStateAlive string = "Alive"
|
||||
// MembershipStateSuspect indicates that the member is active, but at least one cluster member suspects its down.
|
||||
// For all purposes, a Suspect member is treated as if it were Alive until either it refutes the suspicion
|
||||
// (becoming Alive) or fails to do so (being declared Down).
|
||||
MembershipStateSuspect string = "Suspect"
|
||||
// MembershipStateDown indicates that the member is confirmed Down. A member that reaches this state can't join
|
||||
// the cluster with the same identity until the cluster forgets this knowledge.
|
||||
MembershipStateDown string = "Down"
|
||||
)
|
||||
|
||||
func parseClusterMembershipState(json map[string]any) (ClusterMembershipState, error) {
|
||||
// Example JSON:
|
||||
// {
|
||||
// "id": {
|
||||
// "addr": "[fdcc:1d51:6bae:6bb2:53c0:8796:1be0:b783]:51001",
|
||||
// "cluster_id": 0,
|
||||
// "id": "10d69d6f-6578-4dcf-a285-e860e40c5f06",
|
||||
// "ts": 7435936225798880256
|
||||
// },
|
||||
// "incarnation": 0,
|
||||
// "state": "Down"
|
||||
// }
|
||||
|
||||
var state ClusterMembershipState
|
||||
var err error
|
||||
|
||||
idObj, ok := json["id"].(map[string]any)
|
||||
if !ok {
|
||||
return state, fmt.Errorf("missing or invalid 'id' field")
|
||||
}
|
||||
|
||||
// ID
|
||||
if id, ok := idObj["id"].(string); ok {
|
||||
state.ID = id
|
||||
} else {
|
||||
return state, fmt.Errorf("missing or invalid 'id' field")
|
||||
}
|
||||
|
||||
// Addr
|
||||
if addr, ok := idObj["addr"].(string); ok {
|
||||
state.Addr, err = netip.ParseAddrPort(addr)
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("parse 'addr' field: %w", err)
|
||||
}
|
||||
} else {
|
||||
return state, fmt.Errorf("missing or invalid 'addr' field")
|
||||
}
|
||||
|
||||
// State
|
||||
if stateStr, ok := json["state"].(string); ok {
|
||||
switch stateStr {
|
||||
case MembershipStateAlive, MembershipStateSuspect, MembershipStateDown:
|
||||
state.State = stateStr
|
||||
default:
|
||||
return state, fmt.Errorf("invalid 'state' field: %s", stateStr)
|
||||
}
|
||||
} else {
|
||||
return state, fmt.Errorf("missing or invalid 'state' field")
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
if ts, ok := idObj["ts"].(float64); ok {
|
||||
state.Timestamp = ntp64ToTime(uint64(ts))
|
||||
} else {
|
||||
return state, fmt.Errorf("missing or invalid 'ts' field")
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// nt64ToTime converts a 64-bit NTP timestamp relative to the Unix epoch (1st Jan 1970) to time.Time.
|
||||
// See for more details: https://datatracker.ietf.org/doc/html/rfc5905#section-6
|
||||
func ntp64ToTime(ntp uint64) time.Time {
|
||||
// The NTP timestamp returned from Corrosion is relative to the Unix epoch (1st Jan 1970)
|
||||
// so no need to subtract the 70 years offset.
|
||||
secs := uint32(ntp >> 32)
|
||||
frac := uint32(ntp)
|
||||
// Convert the fraction to nanoseconds: frac * 1e9 / 2^32
|
||||
nsecs := (uint64(frac) * 1000_000_000) >> 32
|
||||
|
||||
return time.Unix(int64(secs), int64(nsecs))
|
||||
}
|
||||
|
||||
// ClusterMembershipStates returns the current membership SWIM states of all cluster members.
|
||||
// If latest is true, only the latest state of each member is returned.
|
||||
func (c *AdminClient) ClusterMembershipStates(latest bool) ([]ClusterMembershipState, error) {
|
||||
respCh, err := c.SendCommand([]byte("{\"Cluster\":\"MembershipStates\"}"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
states []ClusterMembershipState
|
||||
latestStates map[string]ClusterMembershipState
|
||||
parseErr error
|
||||
)
|
||||
if latest {
|
||||
latestStates = make(map[string]ClusterMembershipState)
|
||||
}
|
||||
|
||||
for r := range respCh {
|
||||
if r.Err != nil {
|
||||
// It's safe to return here because the channel is closed after the first error response.
|
||||
return nil, r.Err
|
||||
}
|
||||
|
||||
s, err := parseClusterMembershipState(r.JSON)
|
||||
if err != nil {
|
||||
// Do not return early to drain the channel.
|
||||
parseErr = errors.Join(parseErr, err)
|
||||
} else {
|
||||
if latest {
|
||||
if existing, ok := latestStates[s.ID]; !ok || existing.Timestamp.Before(s.Timestamp) {
|
||||
latestStates[s.ID] = s
|
||||
}
|
||||
} else {
|
||||
states = append(states, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if latest {
|
||||
states = make([]ClusterMembershipState, 0, len(latestStates))
|
||||
for _, s := range latestStates {
|
||||
states = append(states, s)
|
||||
}
|
||||
}
|
||||
return states, parseErr
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package corrosion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"golang.org/x/net/http2"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// http2ConnectTimeout is the maximum amount of time an HTTP2 client will wait for a connection to be established.
|
||||
http2ConnectTimeout = 3 * time.Second
|
||||
// http2MaxRetryTime is the maximum amount of time an HTTP2 client will retry a request.
|
||||
http2MaxRetryTime = 10 * time.Second
|
||||
// resubscribeMaxRetryTime is the maximum amount of time an API client will retry resubscribing to a query after
|
||||
// an error occurs.
|
||||
resubscribeMaxRetryTime = 60 * time.Second
|
||||
)
|
||||
|
||||
// APIClient is a client for the Corrosion API.
|
||||
type APIClient struct {
|
||||
baseURL *url.URL
|
||||
client *http.Client
|
||||
newResubBackoff func() backoff.BackOff
|
||||
}
|
||||
|
||||
// NewAPIClient creates a new Corrosion API client. The client retries on network errors using an exponential backoff
|
||||
// policy with a maximum interval of 1 second and a maximum elapsed time of 10 seconds.
|
||||
// It automatically resubscribes to active subscriptions if an error occurs using an exponential backoff policy with a
|
||||
// maximum interval of 1 second and a maximum elapsed time of 60 seconds.
|
||||
// Use the WithHTTP2Client option to provide a custom HTTP client and the WithResubscribeBackoff option to change the
|
||||
// backoff policy for resubscribing to a query.
|
||||
func NewAPIClient(addr netip.AddrPort, opts ...APIClientOption) (*APIClient, error) {
|
||||
baseURL, err := url.Parse(fmt.Sprintf("http://%s", addr))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
c := &APIClient{
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{
|
||||
Transport: &RetryRoundTripper{
|
||||
Base: &http2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: http2ConnectTimeout,
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
},
|
||||
},
|
||||
NewBackoff: func() backoff.BackOff {
|
||||
return backoff.NewExponentialBackOff(
|
||||
backoff.WithInitialInterval(100*time.Millisecond),
|
||||
backoff.WithMaxInterval(1*time.Second),
|
||||
backoff.WithMaxElapsedTime(http2MaxRetryTime),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
newResubBackoff: func() backoff.BackOff {
|
||||
return backoff.NewExponentialBackOff(
|
||||
backoff.WithInitialInterval(100*time.Millisecond),
|
||||
backoff.WithMaxInterval(1*time.Second),
|
||||
backoff.WithMaxElapsedTime(resubscribeMaxRetryTime),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type APIClientOption func(*APIClient)
|
||||
|
||||
func WithHTTP2Client(client *http.Client) APIClientOption {
|
||||
return func(c *APIClient) {
|
||||
c.client = client
|
||||
}
|
||||
}
|
||||
|
||||
// WithResubscribeBackoff sets the backoff policy for resubscribing to a query if an error occurs.
|
||||
// Pass nil to disable resubscribing.
|
||||
func WithResubscribeBackoff(newBackoff func() backoff.BackOff) APIClientOption {
|
||||
return func(c *APIClient) {
|
||||
c.newResubBackoff = newBackoff
|
||||
}
|
||||
}
|
||||
|
||||
type RetryRoundTripper struct {
|
||||
Base http.RoundTripper
|
||||
// NewBackoff creates a new backoff policy for each request.
|
||||
NewBackoff func() backoff.BackOff
|
||||
}
|
||||
|
||||
func (rt *RetryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
roundTrip := func() (*http.Response, error) {
|
||||
resp, err := rt.Base.RoundTrip(req)
|
||||
if err != nil {
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
// Not certain, but I expect operational errors should generally be retryable.
|
||||
slog.Debug("Retrying corrosion API request due to network error.", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
// Don't retry on other errors.
|
||||
return nil, backoff.Permanent(err)
|
||||
}
|
||||
// Success, don't retry.
|
||||
return resp, err
|
||||
}
|
||||
boff := backoff.WithContext(rt.NewBackoff(), req.Context())
|
||||
return backoff.RetryWithData(roundTrip, boff)
|
||||
}
|
||||
|
||||
type RetrySubscription struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
client *APIClient
|
||||
sub *Subscription
|
||||
changes chan *ChangeEvent
|
||||
lastChangeID uint64
|
||||
err error
|
||||
backoff backoff.BackOff
|
||||
}
|
||||
|
||||
func NewRetrySubscription(ctx context.Context, client *APIClient, sub *Subscription, boff backoff.BackOff) *RetrySubscription {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
if boff == nil {
|
||||
boff = backoff.NewExponentialBackOff(
|
||||
backoff.WithInitialInterval(100*time.Millisecond),
|
||||
backoff.WithMaxInterval(1*time.Second),
|
||||
backoff.WithMaxElapsedTime(60*time.Second),
|
||||
)
|
||||
}
|
||||
return &RetrySubscription{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
client: client,
|
||||
sub: sub,
|
||||
backoff: backoff.WithContext(boff, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *RetrySubscription) ID() string {
|
||||
return rs.sub.ID()
|
||||
}
|
||||
|
||||
func (rs *RetrySubscription) Changes() (<-chan *ChangeEvent, error) {
|
||||
if rs.changes != nil {
|
||||
return rs.changes, nil
|
||||
}
|
||||
|
||||
// Ensure the rows are consumed if they have been requested.
|
||||
changes, err := rs.sub.Changes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rs.lastChangeID = rs.sub.lastChangeID
|
||||
|
||||
go func() {
|
||||
defer close(rs.changes)
|
||||
|
||||
var change *ChangeEvent
|
||||
for {
|
||||
select {
|
||||
case change = <-changes:
|
||||
case <-rs.ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
if change != nil {
|
||||
select {
|
||||
case rs.changes <- change:
|
||||
case <-rs.ctx.Done():
|
||||
return
|
||||
}
|
||||
rs.lastChangeID = change.ChangeID
|
||||
continue
|
||||
}
|
||||
|
||||
// The underlying subscription has been closed due to an error or context cancellation.
|
||||
// Return if the context is done or try to resubscribe otherwise.
|
||||
if rs.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = rs.resubscribe(); err != nil {
|
||||
// resubscribe returns a permanent error after unsuccessful retries.
|
||||
rs.err = fmt.Errorf("resubscribe to query: %w", err)
|
||||
return
|
||||
}
|
||||
changes, err = rs.sub.Changes()
|
||||
if err != nil {
|
||||
// Unexpected error but report it anyway.
|
||||
rs.err = err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return rs.changes, nil
|
||||
}
|
||||
|
||||
func (rs *RetrySubscription) resubscribe() error {
|
||||
return backoff.Retry(func() error {
|
||||
slog.Debug("Resubscribing to Corrosion query.", "id", rs.ID(), "from_change", rs.lastChangeID)
|
||||
sub, err := rs.client.ResubscribeContext(rs.ctx, rs.ID(), rs.lastChangeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rs.sub = sub
|
||||
return nil
|
||||
}, rs.backoff)
|
||||
}
|
||||
|
||||
func (rs *RetrySubscription) Err() error {
|
||||
return rs.err
|
||||
}
|
||||
|
||||
func (rs *RetrySubscription) Close() {
|
||||
rs.cancel()
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package corrosion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Statement struct {
|
||||
Query string `json:"query"`
|
||||
Params []any `json:"params"`
|
||||
}
|
||||
|
||||
type ExecResponse struct {
|
||||
Results []ExecResult `json:"results"`
|
||||
Time float64 `json:"time"`
|
||||
Version *uint `json:"version"`
|
||||
}
|
||||
|
||||
type ExecResult struct {
|
||||
RowsAffected uint `json:"rows_affected"`
|
||||
Time float64 `json:"time"`
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
|
||||
// ExecContext writes changes to the Corrosion database for propagation through the cluster. The args are for any
|
||||
// placeholder parameters in the query. Corrosion does not sync schema changes made using this method. Use Corrosion's
|
||||
// schema_files to create and update the cluster's database schema.
|
||||
func (c *APIClient) ExecContext(ctx context.Context, query string, args ...any) (*ExecResult, error) {
|
||||
statements := []Statement{
|
||||
{
|
||||
Query: query,
|
||||
Params: args,
|
||||
},
|
||||
}
|
||||
resp, err := c.ExecMultiContext(ctx, statements...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(resp.Results) == 0 {
|
||||
return nil, fmt.Errorf("no results: %+v", resp)
|
||||
}
|
||||
return &resp.Results[0], nil
|
||||
}
|
||||
|
||||
// ExecMultiContext writes changes to the Corrosion database for propagation through the cluster.
|
||||
// Unlike ExecContext, this method allows multiple statements to be executed in a single transaction.
|
||||
func (c *APIClient) ExecMultiContext(ctx context.Context, statements ...Statement) (*ExecResponse, error) {
|
||||
body, err := json.Marshal(statements)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal queries: %w", err)
|
||||
}
|
||||
|
||||
transactionsURL := c.baseURL.JoinPath("/v1/transactions").String()
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", transactionsURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var execResp ExecResponse
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if err = json.NewDecoder(resp.Body).Decode(&execResp); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
// The response may still contain DB errors even if the status code is OK. Return them along with the response.
|
||||
var errs []error
|
||||
for _, result := range execResp.Results {
|
||||
if result.Error != nil {
|
||||
errs = append(errs, errors.New(*result.Error))
|
||||
}
|
||||
}
|
||||
return &execResp, errors.Join(errs...)
|
||||
} else if resp.StatusCode == http.StatusInternalServerError {
|
||||
// If the response is an Internal Server Error, the response body may contain the error encoded as ExecResponse.
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
if err = json.Unmarshal(respBody, &execResp); err != nil {
|
||||
return nil, fmt.Errorf("internal server error: %s", respBody)
|
||||
}
|
||||
if len(execResp.Results) > 0 && execResp.Results[0].Error != nil {
|
||||
return nil, errors.New(*execResp.Results[0].Error)
|
||||
}
|
||||
return nil, fmt.Errorf("internal server error: %s", respBody)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
type QueryEvent struct {
|
||||
Columns []string `json:"columns"`
|
||||
Row *RowEvent `json:"row"`
|
||||
EOQ *EndOfQuery `json:"eoq"`
|
||||
Change *ChangeEvent `json:"change"`
|
||||
// Error is a server-side error that occurred during query execution. It's considered fatal for the client
|
||||
// as it cannot be recovered from server-side.
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
|
||||
type EndOfQuery struct {
|
||||
Time float64 `json:"time"`
|
||||
ChangeID *uint64 `json:"change_id"`
|
||||
}
|
||||
|
||||
type RowEvent struct {
|
||||
RowID uint64
|
||||
Values []json.RawMessage
|
||||
}
|
||||
|
||||
func (re *RowEvent) UnmarshalJSON(data []byte) error {
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("invalid row event: %w", err)
|
||||
}
|
||||
if len(raw) != 2 {
|
||||
return fmt.Errorf("invalid row event: expected an array of 2 elements")
|
||||
}
|
||||
if err := json.Unmarshal(raw[0], &re.RowID); err != nil {
|
||||
return fmt.Errorf("invalid row event: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw[1], &re.Values); err != nil {
|
||||
return fmt.Errorf("invalid row event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (re *RowEvent) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal([]any{re.RowID, re.Values})
|
||||
}
|
||||
|
||||
// QueryContext executes a query that returns rows, typically a SELECT.
|
||||
// The args are for any placeholder parameters in the query.
|
||||
func (c *APIClient) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) {
|
||||
statement := Statement{
|
||||
Query: query,
|
||||
Params: args,
|
||||
}
|
||||
body, err := json.Marshal(statement)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal query: %w", err)
|
||||
}
|
||||
|
||||
queriesURL := c.baseURL.JoinPath("/v1/queries").String()
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", queriesURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
rows, err := newRows(ctx, resp.Body, true)
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("parse query response: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Rows is the result of a query. Its cursor starts before the first row of the result set.
|
||||
// Use [Rows.Next] to advance from row to row.
|
||||
type Rows struct {
|
||||
ctx context.Context
|
||||
body io.ReadCloser
|
||||
decoder *json.Decoder
|
||||
eoq *EndOfQuery
|
||||
closeOnEOQ bool
|
||||
|
||||
columns []string
|
||||
row RowEvent
|
||||
err error
|
||||
}
|
||||
|
||||
func newRows(ctx context.Context, body io.ReadCloser, closeOnEOQ bool) (*Rows, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(body)
|
||||
var e QueryEvent
|
||||
if err := decoder.Decode(&e); err != nil {
|
||||
return nil, fmt.Errorf("decode query event: %w", err)
|
||||
}
|
||||
if e.Columns == nil {
|
||||
return nil, fmt.Errorf("expected columns event, got: %+v", e)
|
||||
}
|
||||
|
||||
return &Rows{
|
||||
ctx: ctx,
|
||||
body: body,
|
||||
decoder: decoder,
|
||||
closeOnEOQ: closeOnEOQ,
|
||||
columns: e.Columns,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Columns returns the column names.
|
||||
func (rs *Rows) Columns() []string {
|
||||
return rs.columns
|
||||
}
|
||||
|
||||
// Next prepares the next result row for reading with the [Rows.Scan] method. It returns true on success, or false
|
||||
// if there is no next result row or an error happened while preparing it. [Rows.Err] should be consulted to distinguish
|
||||
// between the two cases.
|
||||
//
|
||||
// Every call to [Rows.Scan], even the first one, must be preceded by a call to [Rows.Next].
|
||||
func (rs *Rows) Next() bool {
|
||||
select {
|
||||
case <-rs.ctx.Done():
|
||||
rs.err = rs.ctx.Err()
|
||||
_ = rs.Close()
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
var e QueryEvent
|
||||
if err := rs.decoder.Decode(&e); err != nil {
|
||||
rs.err = fmt.Errorf("decode query event: %w", err)
|
||||
_ = rs.Close()
|
||||
return false
|
||||
}
|
||||
// Server-side query error.
|
||||
if e.Error != nil {
|
||||
rs.err = fmt.Errorf("query error: %s", *e.Error)
|
||||
_ = rs.Close()
|
||||
return false
|
||||
}
|
||||
|
||||
if e.Row != nil {
|
||||
if len(e.Row.Values) != len(rs.columns) {
|
||||
rs.err = fmt.Errorf("expected %d column values, got %d", len(rs.columns), len(e.Row.Values))
|
||||
_ = rs.Close()
|
||||
return false
|
||||
}
|
||||
rs.row = *e.Row
|
||||
return true
|
||||
}
|
||||
if e.EOQ != nil {
|
||||
rs.eoq = e.EOQ
|
||||
// Rows could be used as part of a subscription, so don't close the body if so.
|
||||
if rs.closeOnEOQ {
|
||||
_ = rs.Close()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
rs.err = fmt.Errorf("expected row or eof event, got: %+v", e)
|
||||
_ = rs.Close()
|
||||
return false
|
||||
}
|
||||
|
||||
// Err returns the error, if any, that was encountered during iteration.
|
||||
// Err may be called after an explicit or implicit [Rows.Close].
|
||||
func (rs *Rows) Err() error {
|
||||
return rs.err
|
||||
}
|
||||
|
||||
// Scan copies the columns in the current row into the values pointed at by dest.
|
||||
// The number of values in dest must be the same as the number of columns in [Rows].
|
||||
// Scan converts JSON-encoded column values to the provided Go types using [json.Unmarshal].
|
||||
func (rs *Rows) Scan(dest ...any) error {
|
||||
if rs.err != nil {
|
||||
return rs.err
|
||||
}
|
||||
if len(dest) != len(rs.columns) {
|
||||
return fmt.Errorf("expected %d values, got %d", len(rs.columns), len(dest))
|
||||
}
|
||||
|
||||
for i, v := range rs.row.Values {
|
||||
if err := json.Unmarshal(v, dest[i]); err != nil {
|
||||
return fmt.Errorf("unmarshal column value #%d: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Time returns the time taken to execute the query in seconds. It's only available after all rows have been consumed.
|
||||
// It doesn't include the time to send the query, receive the response, or iterate over the rows.
|
||||
func (rs *Rows) Time() (float64, error) {
|
||||
if rs.eoq == nil {
|
||||
if rs.Err() != nil {
|
||||
return 0, fmt.Errorf("time is not available: %w", rs.Err())
|
||||
}
|
||||
return 0, errors.New("time is not available until all rows are consumed")
|
||||
}
|
||||
return rs.eoq.Time, nil
|
||||
}
|
||||
|
||||
// Close closes the [Rows], preventing further enumeration. If [Rows.Next] is called and returns false,
|
||||
// the [Rows] are closed automatically and it will suffice to check the result of [Rows.Err].
|
||||
// Close is idempotent and does not affect the result of [Rows.Err].
|
||||
func (rs *Rows) Close() error {
|
||||
return rs.body.Close()
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package corrosion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type ChangeType string
|
||||
|
||||
var (
|
||||
ChangeTypeInsert ChangeType = "insert"
|
||||
ChangeTypeUpdate ChangeType = "update"
|
||||
ChangeTypeDelete ChangeType = "delete"
|
||||
)
|
||||
|
||||
type ChangeEvent struct {
|
||||
Type ChangeType
|
||||
RowID uint64
|
||||
Values []json.RawMessage
|
||||
ChangeID uint64
|
||||
}
|
||||
|
||||
func (ce *ChangeEvent) UnmarshalJSON(data []byte) error {
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("invalid change event: %w", err)
|
||||
}
|
||||
if len(raw) != 4 {
|
||||
return fmt.Errorf("invalid change event: expected an array of 4 elements")
|
||||
}
|
||||
if err := json.Unmarshal(raw[0], &ce.Type); err != nil {
|
||||
return fmt.Errorf("invalid change event type: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw[1], &ce.RowID); err != nil {
|
||||
return fmt.Errorf("invalid change event row ID: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw[2], &ce.Values); err != nil {
|
||||
return fmt.Errorf("invalid change event values: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw[3], &ce.ChangeID); err != nil {
|
||||
return fmt.Errorf("invalid change event change ID: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ce *ChangeEvent) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal([]any{ce.Type, ce.RowID, ce.Values, ce.ChangeID})
|
||||
}
|
||||
|
||||
// Scan copies the column values in the change event into the values pointed at by dest.
|
||||
// The number of values in dest must be the same as the number of columns in the change.
|
||||
// Scan converts JSON-encoded column values to the provided Go types using [json.Unmarshal].
|
||||
func (ce *ChangeEvent) Scan(dest ...any) error {
|
||||
if len(dest) != len(ce.Values) {
|
||||
return fmt.Errorf("expected %d values, got %d", len(ce.Values), len(dest))
|
||||
}
|
||||
|
||||
for i, v := range ce.Values {
|
||||
if err := json.Unmarshal(v, dest[i]); err != nil {
|
||||
return fmt.Errorf("unmarshal column value #%d: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscription receives updates from the Corrosion database for a desired SQL query.
|
||||
type Subscription struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
id string
|
||||
rows *Rows
|
||||
body io.ReadCloser
|
||||
decoder *json.Decoder
|
||||
resubscribe func(ctx context.Context, fromChange uint64) (*Subscription, error)
|
||||
changes chan *ChangeEvent
|
||||
lastChangeID uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func newSubscription(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
rows *Rows,
|
||||
body io.ReadCloser,
|
||||
decoder *json.Decoder,
|
||||
resubscribe func(ctx context.Context, fromChange uint64) (*Subscription, error),
|
||||
) *Subscription {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
if decoder == nil {
|
||||
decoder = json.NewDecoder(body)
|
||||
}
|
||||
return &Subscription{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: id,
|
||||
rows: rows,
|
||||
body: body,
|
||||
decoder: decoder,
|
||||
resubscribe: resubscribe,
|
||||
}
|
||||
}
|
||||
|
||||
// ID returns the subscription ID.
|
||||
func (s *Subscription) ID() string {
|
||||
return s.id
|
||||
}
|
||||
|
||||
// Rows returns the rows of the query or nil if skipRows was true when creating the subscription or if the subscription
|
||||
// was created with [APIClient.ResubscribeContext].
|
||||
func (s *Subscription) Rows() *Rows {
|
||||
return s.rows
|
||||
}
|
||||
|
||||
// Changes returns a channel that receives change events for the query. Changes are not available until all rows
|
||||
// are consumed. The channel is closed when the context is done, or an error occurs while reading the changes,
|
||||
// or when the subscription is closed explicitly. If it's closed due to an error, [Subscription.Err] will return
|
||||
// the error.
|
||||
func (s *Subscription) Changes() (<-chan *ChangeEvent, error) {
|
||||
if s.changes != nil {
|
||||
return s.changes, nil
|
||||
}
|
||||
|
||||
if s.rows != nil {
|
||||
if s.rows.eoq == nil {
|
||||
return nil, errors.New("changes are not available until all rows are consumed")
|
||||
}
|
||||
s.lastChangeID = *s.rows.eoq.ChangeID
|
||||
}
|
||||
s.changes = make(chan *ChangeEvent)
|
||||
|
||||
go func() {
|
||||
// Close the body when the context is done to unblock the decoder in the following goroutine.
|
||||
<-s.ctx.Done()
|
||||
s.body.Close()
|
||||
}()
|
||||
go s.handleChangeEvents()
|
||||
|
||||
return s.changes, nil
|
||||
}
|
||||
|
||||
func (s *Subscription) handleChangeEvents() {
|
||||
defer s.cancel()
|
||||
defer close(s.changes)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
var e QueryEvent
|
||||
var err error
|
||||
if err = s.decoder.Decode(&e); err != nil {
|
||||
// Do not report an error that occurred due to context cancellation, just return.
|
||||
if s.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
err = fmt.Errorf("decode query event: %w", err)
|
||||
} else if e.Error != nil {
|
||||
err = fmt.Errorf("query error: %s", *e.Error)
|
||||
} else if e.Change == nil {
|
||||
err = fmt.Errorf("expected change event, got: %+v", e)
|
||||
} else if s.lastChangeID != 0 && e.Change.ChangeID != s.lastChangeID+1 {
|
||||
// If skipRows is true, the last change ID is unknown.
|
||||
err = fmt.Errorf("missed a change: expected change ID %d, got %d",
|
||||
s.lastChangeID+1, e.Change.ChangeID)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
s.lastChangeID = e.Change.ChangeID
|
||||
select {
|
||||
case s.changes <- e.Change:
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Report the error if resubscribing is disabled.
|
||||
if s.resubscribe == nil {
|
||||
s.err = err
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Resubscribing to Corrosion query due to an error.",
|
||||
"err", err, "id", s.id, "from_change", s.lastChangeID)
|
||||
sub, sErr := s.resubscribe(s.ctx, s.lastChangeID)
|
||||
if sErr != nil {
|
||||
// resubscribe returns a permanent error after unsuccessful retries.
|
||||
s.err = fmt.Errorf("resubscribe to query with backoff: %w", sErr)
|
||||
return
|
||||
}
|
||||
// Reset the subscription to the new one.
|
||||
s.rows = nil
|
||||
s.body = sub.body
|
||||
s.decoder = sub.decoder
|
||||
// Do not close the sub to not close the body.
|
||||
sub.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Err returns the error, if any, that was encountered during fetching changes.
|
||||
// Err may be called after an explicit or implicit [Subscription.Close].
|
||||
func (s *Subscription) Err() error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s *Subscription) Close() error {
|
||||
s.cancel()
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
// SubscribeContext creates a subscription to receive updates for a desired SQL query. If skipRows is false,
|
||||
// Subscription.Rows must be consumed before Subscription.Changes can be called. If skipRows is true, Subscription.Rows
|
||||
// will return nil.
|
||||
func (c *APIClient) SubscribeContext(
|
||||
ctx context.Context, query string, args []any, skipRows bool,
|
||||
) (*Subscription, error) {
|
||||
statement := Statement{
|
||||
Query: query,
|
||||
Params: args,
|
||||
}
|
||||
body, err := json.Marshal(statement)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal query: %w", err)
|
||||
}
|
||||
|
||||
subURL := c.baseURL.JoinPath("/v1/subscriptions")
|
||||
if skipRows {
|
||||
q := subURL.Query()
|
||||
q.Set("skip_rows", "true")
|
||||
subURL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", subURL.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
id := resp.Header.Get("corro-query-id")
|
||||
if id == "" {
|
||||
resp.Body.Close()
|
||||
return nil, errors.New("missing corro-query-id header in response")
|
||||
}
|
||||
|
||||
if skipRows {
|
||||
return newSubscription(ctx, id, nil, resp.Body, nil, c.resubscribeWithBackoffFn(id)), nil
|
||||
}
|
||||
|
||||
rows, err := newRows(ctx, resp.Body, false)
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("parse query response: %w", err)
|
||||
}
|
||||
return newSubscription(ctx, id, rows, rows.body, rows.decoder, c.resubscribeWithBackoffFn(id)), nil
|
||||
}
|
||||
|
||||
func (c *APIClient) resubscribeWithBackoffFn(id string) func(context.Context, uint64) (*Subscription, error) {
|
||||
if c.newResubBackoff == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, fromChange uint64) (*Subscription, error) {
|
||||
return backoff.RetryWithData(func() (*Subscription, error) {
|
||||
slog.Debug("Retrying to resubscribe to Corrosion query.", "id", id, "from_change", fromChange)
|
||||
return c.ResubscribeContext(ctx, id, fromChange)
|
||||
}, c.newResubBackoff())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *APIClient) ResubscribeContext(ctx context.Context, id string, fromChange uint64) (*Subscription, error) {
|
||||
subURL := c.baseURL.JoinPath("/v1/subscriptions", id)
|
||||
q := subURL.Query()
|
||||
q.Set("from", strconv.FormatUint(fromChange, 10))
|
||||
subURL.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", subURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
return newSubscription(ctx, id, nil, resp.Body, nil, c.resubscribeWithBackoffFn(id)), nil
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
systemd "github.com/coreos/go-systemd/daemon"
|
||||
"log/slog"
|
||||
"uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
)
|
||||
|
||||
type Daemon struct {
|
||||
@@ -35,7 +35,7 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||
case <-d.machine.Started():
|
||||
_, err := systemd.SdNotify(false, systemd.SdNotifyReady)
|
||||
if err != nil {
|
||||
slog.Error("Failed to notify systemd that the daemon is ready.", "error", err)
|
||||
slog.Error("Failed to notify systemd that the daemon is ready.", "err", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package daemon
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"net/netip"
|
||||
"os"
|
||||
"uncloud/internal/machine"
|
||||
"uncloud/internal/machine/network"
|
||||
)
|
||||
|
||||
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
|
||||
@@ -37,5 +37,5 @@ func MachineToken(dataDir string) (machine.Token, error) {
|
||||
for i, ip := range ips {
|
||||
endpoints[i] = netip.AddrPortFrom(ip, network.WireGuardPort)
|
||||
}
|
||||
return machine.NewToken(state.Network.PublicKey, endpoints), nil
|
||||
return machine.NewToken(state.Network.PublicKey, publicIP, endpoints), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package dns
|
||||
|
||||
const (
|
||||
RecordTypeA RecordType = "A"
|
||||
RecordTypeAAAA RecordType = "AAAA"
|
||||
)
|
||||
|
||||
type RecordType string
|
||||
|
||||
type DomainResponse struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
type RecordRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Type RecordType `json:"type,omitempty"`
|
||||
Values []string `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
type RecordResponse struct {
|
||||
RecordRequest
|
||||
FQDN string `json:"fqdn,omitempty"`
|
||||
}
|
||||
|
||||
type AuthErrorResponse struct {
|
||||
Status int `json:"status,omitempty"`
|
||||
Message string `json:"msg,omitempty"`
|
||||
Data authErrorData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type authErrorData struct {
|
||||
NoDomain bool `json:"noDomain,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The dns package code is based on https://github.com/acorn-io/runtime/blob/main/pkg/dns.
|
||||
|
||||
// Client handles interactions with the Uncloud DNS API service.
|
||||
type Client interface {
|
||||
// ReserveDomain calls Uncloud DNS to reserve a new domain. It returns the domain, a token for authentication,
|
||||
// and an error.
|
||||
ReserveDomain(endpoint string) (string, string, error)
|
||||
|
||||
// CreateRecords calls Uncloud DNS to create or update DNS records based on the supplied RecordRequests
|
||||
// for the specified domain.
|
||||
CreateRecords(endpoint, domain, token string, records []RecordRequest) ([]RecordResponse, error)
|
||||
}
|
||||
|
||||
// ErrAuthNoDomain indicates that a request failed authentication because the domain was not found.
|
||||
// If encountered, a new domain needs to be reserved.
|
||||
var ErrAuthNoDomain = errors.New("the supplied domain failed authentication")
|
||||
|
||||
// NewClient creates a new AcornDNS client
|
||||
func NewClient() Client {
|
||||
return &client{
|
||||
c: http.DefaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
c *http.Client
|
||||
}
|
||||
|
||||
func (c *client) ReserveDomain(endpoint string) (string, string, error) {
|
||||
url := fmt.Sprintf("%s/%s", endpoint, "domains")
|
||||
|
||||
req, err := c.request(http.MethodPost, url, nil, "")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
resp := &DomainResponse{}
|
||||
err = c.do(req, resp)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return resp.Name, resp.Token, nil
|
||||
}
|
||||
|
||||
func (c *client) CreateRecords(endpoint, domain, token string, records []RecordRequest) ([]RecordResponse, error) {
|
||||
url := fmt.Sprintf("%s/domains/%s/records", endpoint, domain)
|
||||
|
||||
var resp []RecordResponse
|
||||
for _, recordRequest := range records {
|
||||
body, err := jsonBody(recordRequest)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
req, err := c.request(http.MethodPost, url, body, token)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
var recordResp RecordResponse
|
||||
if err = c.do(req, &recordResp); err != nil {
|
||||
return resp, err
|
||||
}
|
||||
resp = append(resp, recordResp)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *client) request(method string, url string, body io.Reader, token string) (*http.Request, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
if token != "" {
|
||||
bearer := "Bearer " + token
|
||||
req.Header.Add("Authorization", bearer)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *client) do(req *http.Request, responseBody any) error {
|
||||
slog.Debug("Making request to DNS service.", "method", req.Method, "url", req.URL)
|
||||
|
||||
resp, err := c.c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("Response code for request to DNS service.",
|
||||
"method", req.Method, "url", req.URL, "code", resp.StatusCode)
|
||||
// When err is nil, resp contains a non-nil resp.Body which must be closed.
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
var authError AuthErrorResponse
|
||||
|
||||
err = json.Unmarshal(body, &authError)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal auth error response: %w", err)
|
||||
}
|
||||
|
||||
if authError.Data.NoDomain {
|
||||
return ErrAuthNoDomain
|
||||
}
|
||||
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
|
||||
if code := resp.StatusCode; code < 200 || code > 300 {
|
||||
return fmt.Errorf("unexpected response status code: %d", code)
|
||||
}
|
||||
|
||||
if responseBody != nil {
|
||||
err = json.Unmarshal(body, responseBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal response body (%s): %w", string(body), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonBody(payload any) (io.Reader, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
err := json.NewEncoder(buf).Encode(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/docker/docker/client"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
||||
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
|
||||
// Retry to ping the Docker daemon until it's ready or the context is canceled.
|
||||
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||
backoff.WithInitialInterval(100*time.Millisecond),
|
||||
backoff.WithMaxInterval(1*time.Second),
|
||||
backoff.WithMaxElapsedTime(0),
|
||||
), ctx)
|
||||
|
||||
waitingLogged := false
|
||||
ping := func() error {
|
||||
_, err := cli.Ping(ctx)
|
||||
if err == nil {
|
||||
if waitingLogged {
|
||||
slog.Info("Docker daemon is ready.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !client.IsErrConnectionFailed(err) {
|
||||
return backoff.Permanent(fmt.Errorf("connect to Docker daemon: %w", err))
|
||||
}
|
||||
|
||||
if !waitingLogged {
|
||||
slog.Info("Waiting for Docker daemon to start and be ready.")
|
||||
waitingLogged = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := backoff.Retry(ping, boff); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ping Docker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ExpandHomeDir(path string) string {
|
||||
if len(path) == 0 {
|
||||
return path
|
||||
}
|
||||
if path[0] == '~' {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return strings.Replace(path, "~", home, 1)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// LookupUIDGID returns the user and group IDs for the given username.
|
||||
func LookupUIDGID(username string) (uid, gid int, err error) {
|
||||
usr, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("lookup user %q: %w", username, err)
|
||||
return
|
||||
}
|
||||
uid, err = strconv.Atoi(usr.Uid)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("parse %q user ID (UID) %q: %w", username, usr.Uid, err)
|
||||
return
|
||||
}
|
||||
gid, err = strconv.Atoi(usr.Gid)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("parse %q user group ID (GID) %q: %w", username, usr.Gid, err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Chown(path, username, group string) error {
|
||||
uid, gid := -1, -1
|
||||
if username != "" {
|
||||
usr, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup user %q: %w", username, err)
|
||||
}
|
||||
uid, err = strconv.Atoi(usr.Uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %q user ID (UID) %q: %w", username, usr.Uid, err)
|
||||
}
|
||||
}
|
||||
|
||||
if group != "" {
|
||||
grp, err := user.LookupGroup(group)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup group %q: %w", group, err)
|
||||
}
|
||||
gid, err = strconv.Atoi(grp.Gid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %q group ID (GID) %q: %w", group, grp.Gid, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Chown(path, uid, gid); err != nil {
|
||||
return fmt.Errorf("chown %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExpandHomeDir(t *testing.T) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
assert.Equal(t, "", ExpandHomeDir(""))
|
||||
})
|
||||
|
||||
t.Run("no home", func(t *testing.T) {
|
||||
assert.Equal(t, "/path", ExpandHomeDir("/path"))
|
||||
})
|
||||
|
||||
t.Run("home", func(t *testing.T) {
|
||||
t.Setenv("HOME", "/home/user")
|
||||
assert.Equal(t, "/home/user/path", ExpandHomeDir("~/path"))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SlogTextHandler extends the standard [slog.TextHandler] to provide custom formatting that is very similar
|
||||
// to the default slog handler. It prefixes each log entry with a right-padded level indicator and message,
|
||||
// while excluding the default time, level, and message attributes from the structured output.
|
||||
//
|
||||
// The output format is:
|
||||
// LEVEL MESSAGE key1=value1 key2=value2
|
||||
type SlogTextHandler struct {
|
||||
*slog.TextHandler
|
||||
opts slog.HandlerOptions
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func NewSlogTextHandler(w io.Writer, opts *slog.HandlerOptions) *SlogTextHandler {
|
||||
if opts == nil {
|
||||
opts = &slog.HandlerOptions{}
|
||||
}
|
||||
// Remove time, level, and message from the default attributes.
|
||||
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey || a.Key == slog.LevelKey || a.Key == slog.MessageKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
return &SlogTextHandler{
|
||||
TextHandler: slog.NewTextHandler(w, opts),
|
||||
opts: *opts,
|
||||
w: w,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SlogTextHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.TextHandler.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h *SlogTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &SlogTextHandler{
|
||||
TextHandler: h.TextHandler.WithAttrs(attrs).(*slog.TextHandler),
|
||||
opts: h.opts,
|
||||
w: h.w,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SlogTextHandler) WithGroup(name string) slog.Handler {
|
||||
return &SlogTextHandler{
|
||||
TextHandler: h.TextHandler.WithGroup(name).(*slog.TextHandler),
|
||||
opts: h.opts,
|
||||
w: h.w,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SlogTextHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
// Only log entries that are at or above the minimum level (INFO by default).
|
||||
minLevel := slog.LevelInfo
|
||||
if h.opts.Level != nil {
|
||||
minLevel = h.opts.Level.Level()
|
||||
}
|
||||
if r.Level < minLevel {
|
||||
return nil
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
levelMsg := fmt.Sprintf("%-5s %s ", r.Level.String(), r.Message)
|
||||
if _, err := h.w.Write([]byte(levelMsg)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.TextHandler.Handle(ctx, r)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
@@ -40,10 +39,3 @@ func (c *NetworkConfig) Validate() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AddMachineRequest) Validate() error {
|
||||
if r.Network == nil {
|
||||
return fmt.Errorf("network not set")
|
||||
}
|
||||
return r.Network.Validate()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,62 +6,73 @@ option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "internal/machine/api/pb/common.proto";
|
||||
import "internal/machine/api/pb/machine.proto";
|
||||
|
||||
service Cluster {
|
||||
rpc AddMachine(AddMachineRequest) returns (AddMachineResponse);
|
||||
rpc ListMachines(google.protobuf.Empty) returns (ListMachinesResponse);
|
||||
rpc ListMachineEndpoints(ListMachineEndpointsRequest) returns (ListMachineEndpointsResponse);
|
||||
|
||||
rpc ReserveDomain(ReserveDomainRequest) returns (Domain);
|
||||
rpc GetDomain(google.protobuf.Empty) returns (Domain);
|
||||
rpc ReleaseDomain(google.protobuf.Empty) returns (Domain);
|
||||
rpc CreateDomainRecords(CreateDomainRecordsRequest) returns (CreateDomainRecordsResponse);
|
||||
}
|
||||
|
||||
message AddMachineRequest {
|
||||
string name = 1;
|
||||
NetworkConfig network = 2;
|
||||
IP public_ip = 3;
|
||||
}
|
||||
|
||||
message AddMachineResponse {
|
||||
MachineInfo machine = 1;
|
||||
}
|
||||
|
||||
message MachineMember {
|
||||
MachineInfo machine = 1;
|
||||
|
||||
enum MembershipState {
|
||||
UNKNOWN = 0;
|
||||
// The member is active.
|
||||
UP = 1;
|
||||
// The member is active, but at least one cluster member suspects its down. For all purposes,
|
||||
// a SUSPECT member is treated as if it were UP until either it refutes the suspicion (becoming UP)
|
||||
// or fails to do so (being declared DOWN).
|
||||
SUSPECT = 2;
|
||||
// The member is confirmed DOWN.
|
||||
DOWN = 3;
|
||||
}
|
||||
MembershipState state = 2;
|
||||
}
|
||||
|
||||
message ListMachinesResponse {
|
||||
repeated MachineInfo machines = 1;
|
||||
repeated MachineMember machines = 1;
|
||||
}
|
||||
|
||||
message ListMachineEndpointsRequest {
|
||||
string id = 1;
|
||||
message Domain {
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
message ListMachineEndpointsResponse {
|
||||
MachineEndpoints endpoints = 1;
|
||||
message ReserveDomainRequest {
|
||||
string endpoint = 1;
|
||||
}
|
||||
|
||||
message MachineInfo {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
NetworkConfig network = 3;
|
||||
message CreateDomainRecordsRequest {
|
||||
repeated DNSRecord records = 1;
|
||||
}
|
||||
|
||||
message NetworkConfig {
|
||||
IPPrefix subnet = 1;
|
||||
IP management_ip = 2;
|
||||
repeated IPPort endpoints = 3;
|
||||
bytes publicKey = 4;
|
||||
message CreateDomainRecordsResponse {
|
||||
repeated DNSRecord records = 1;
|
||||
}
|
||||
|
||||
message MachineEndpoints {
|
||||
string id = 1;
|
||||
repeated IPPort endpoints = 2;
|
||||
}
|
||||
message DNSRecord {
|
||||
string name = 1;
|
||||
|
||||
message User {
|
||||
NetworkConfig network = 1;
|
||||
enum RecordType {
|
||||
UNSPECIFIED = 0;
|
||||
A = 1;
|
||||
AAAA = 2;
|
||||
}
|
||||
|
||||
message State {
|
||||
IPPrefix Network = 1;
|
||||
// The machine configuration in the state is the source of truth set by the administrator.
|
||||
// The machine itself can't update it.
|
||||
map<string, MachineInfo> machines = 2;
|
||||
map<string, MachineEndpoints> endpoints = 3;
|
||||
repeated User users = 4;
|
||||
RecordType type = 2;
|
||||
repeated string values = 3;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ const _ = grpc.SupportPackageIsVersion9
|
||||
const (
|
||||
Cluster_AddMachine_FullMethodName = "/api.Cluster/AddMachine"
|
||||
Cluster_ListMachines_FullMethodName = "/api.Cluster/ListMachines"
|
||||
Cluster_ListMachineEndpoints_FullMethodName = "/api.Cluster/ListMachineEndpoints"
|
||||
Cluster_ReserveDomain_FullMethodName = "/api.Cluster/ReserveDomain"
|
||||
Cluster_GetDomain_FullMethodName = "/api.Cluster/GetDomain"
|
||||
Cluster_ReleaseDomain_FullMethodName = "/api.Cluster/ReleaseDomain"
|
||||
Cluster_CreateDomainRecords_FullMethodName = "/api.Cluster/CreateDomainRecords"
|
||||
)
|
||||
|
||||
// ClusterClient is the client API for Cluster service.
|
||||
@@ -31,7 +34,10 @@ const (
|
||||
type ClusterClient interface {
|
||||
AddMachine(ctx context.Context, in *AddMachineRequest, opts ...grpc.CallOption) (*AddMachineResponse, error)
|
||||
ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListMachinesResponse, error)
|
||||
ListMachineEndpoints(ctx context.Context, in *ListMachineEndpointsRequest, opts ...grpc.CallOption) (*ListMachineEndpointsResponse, error)
|
||||
ReserveDomain(ctx context.Context, in *ReserveDomainRequest, opts ...grpc.CallOption) (*Domain, error)
|
||||
GetDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error)
|
||||
ReleaseDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error)
|
||||
CreateDomainRecords(ctx context.Context, in *CreateDomainRecordsRequest, opts ...grpc.CallOption) (*CreateDomainRecordsResponse, error)
|
||||
}
|
||||
|
||||
type clusterClient struct {
|
||||
@@ -62,10 +68,40 @@ func (c *clusterClient) ListMachines(ctx context.Context, in *emptypb.Empty, opt
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clusterClient) ListMachineEndpoints(ctx context.Context, in *ListMachineEndpointsRequest, opts ...grpc.CallOption) (*ListMachineEndpointsResponse, error) {
|
||||
func (c *clusterClient) ReserveDomain(ctx context.Context, in *ReserveDomainRequest, opts ...grpc.CallOption) (*Domain, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListMachineEndpointsResponse)
|
||||
err := c.cc.Invoke(ctx, Cluster_ListMachineEndpoints_FullMethodName, in, out, cOpts...)
|
||||
out := new(Domain)
|
||||
err := c.cc.Invoke(ctx, Cluster_ReserveDomain_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clusterClient) GetDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Domain)
|
||||
err := c.cc.Invoke(ctx, Cluster_GetDomain_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clusterClient) ReleaseDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Domain)
|
||||
err := c.cc.Invoke(ctx, Cluster_ReleaseDomain_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clusterClient) CreateDomainRecords(ctx context.Context, in *CreateDomainRecordsRequest, opts ...grpc.CallOption) (*CreateDomainRecordsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateDomainRecordsResponse)
|
||||
err := c.cc.Invoke(ctx, Cluster_CreateDomainRecords_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,7 +114,10 @@ func (c *clusterClient) ListMachineEndpoints(ctx context.Context, in *ListMachin
|
||||
type ClusterServer interface {
|
||||
AddMachine(context.Context, *AddMachineRequest) (*AddMachineResponse, error)
|
||||
ListMachines(context.Context, *emptypb.Empty) (*ListMachinesResponse, error)
|
||||
ListMachineEndpoints(context.Context, *ListMachineEndpointsRequest) (*ListMachineEndpointsResponse, error)
|
||||
ReserveDomain(context.Context, *ReserveDomainRequest) (*Domain, error)
|
||||
GetDomain(context.Context, *emptypb.Empty) (*Domain, error)
|
||||
ReleaseDomain(context.Context, *emptypb.Empty) (*Domain, error)
|
||||
CreateDomainRecords(context.Context, *CreateDomainRecordsRequest) (*CreateDomainRecordsResponse, error)
|
||||
mustEmbedUnimplementedClusterServer()
|
||||
}
|
||||
|
||||
@@ -95,8 +134,17 @@ func (UnimplementedClusterServer) AddMachine(context.Context, *AddMachineRequest
|
||||
func (UnimplementedClusterServer) ListMachines(context.Context, *emptypb.Empty) (*ListMachinesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListMachines not implemented")
|
||||
}
|
||||
func (UnimplementedClusterServer) ListMachineEndpoints(context.Context, *ListMachineEndpointsRequest) (*ListMachineEndpointsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListMachineEndpoints not implemented")
|
||||
func (UnimplementedClusterServer) ReserveDomain(context.Context, *ReserveDomainRequest) (*Domain, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReserveDomain not implemented")
|
||||
}
|
||||
func (UnimplementedClusterServer) GetDomain(context.Context, *emptypb.Empty) (*Domain, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetDomain not implemented")
|
||||
}
|
||||
func (UnimplementedClusterServer) ReleaseDomain(context.Context, *emptypb.Empty) (*Domain, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReleaseDomain not implemented")
|
||||
}
|
||||
func (UnimplementedClusterServer) CreateDomainRecords(context.Context, *CreateDomainRecordsRequest) (*CreateDomainRecordsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateDomainRecords not implemented")
|
||||
}
|
||||
func (UnimplementedClusterServer) mustEmbedUnimplementedClusterServer() {}
|
||||
func (UnimplementedClusterServer) testEmbeddedByValue() {}
|
||||
@@ -155,20 +203,74 @@ func _Cluster_ListMachines_Handler(srv interface{}, ctx context.Context, dec fun
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Cluster_ListMachineEndpoints_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListMachineEndpointsRequest)
|
||||
func _Cluster_ReserveDomain_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReserveDomainRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClusterServer).ListMachineEndpoints(ctx, in)
|
||||
return srv.(ClusterServer).ReserveDomain(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Cluster_ListMachineEndpoints_FullMethodName,
|
||||
FullMethod: Cluster_ReserveDomain_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClusterServer).ListMachineEndpoints(ctx, req.(*ListMachineEndpointsRequest))
|
||||
return srv.(ClusterServer).ReserveDomain(ctx, req.(*ReserveDomainRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Cluster_GetDomain_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(emptypb.Empty)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClusterServer).GetDomain(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Cluster_GetDomain_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClusterServer).GetDomain(ctx, req.(*emptypb.Empty))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Cluster_ReleaseDomain_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(emptypb.Empty)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClusterServer).ReleaseDomain(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Cluster_ReleaseDomain_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClusterServer).ReleaseDomain(ctx, req.(*emptypb.Empty))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Cluster_CreateDomainRecords_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateDomainRecordsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClusterServer).CreateDomainRecords(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Cluster_CreateDomainRecords_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClusterServer).CreateDomainRecords(ctx, req.(*CreateDomainRecordsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
@@ -189,8 +291,20 @@ var Cluster_ServiceDesc = grpc.ServiceDesc{
|
||||
Handler: _Cluster_ListMachines_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListMachineEndpoints",
|
||||
Handler: _Cluster_ListMachineEndpoints_Handler,
|
||||
MethodName: "ReserveDomain",
|
||||
Handler: _Cluster_ReserveDomain_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetDomain",
|
||||
Handler: _Cluster_GetDomain_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ReleaseDomain",
|
||||
Handler: _Cluster_ReleaseDomain_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateDomainRecords",
|
||||
Handler: _Cluster_CreateDomainRecords_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user