mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5687cd713 | ||
|
|
a67f936cc1 | ||
|
|
51b8c29d63 | ||
|
|
e6f013dbc8 | ||
|
|
897f03aafd | ||
|
|
7b306f25ce | ||
|
|
78a3551c77 | ||
|
|
38c7dda6a0 | ||
|
|
51c7b5ba8f | ||
|
|
a507b202ec | ||
|
|
d9934b9dba | ||
|
|
1f328e99d5 | ||
|
|
3c435434a8 | ||
|
|
e54871758b | ||
|
|
c039390ade | ||
|
|
bad0f97b66 | ||
|
|
2405b156e4 | ||
|
|
4de17cb63c | ||
|
|
2222434934 | ||
|
|
3f1a772288 | ||
|
|
7b9bed0780 | ||
|
|
aef4b2213f | ||
|
|
44013da332 | ||
|
|
b48ebe2acc | ||
|
|
eaa4455320 | ||
|
|
ce3cb8a34f | ||
|
|
e6705599b3 | ||
|
|
7b88040c73 | ||
|
|
7a9eac593f | ||
|
|
17c1bb5c21 | ||
|
|
a5a9ef5444 | ||
|
|
2940965e31 | ||
|
|
185cf19a0c | ||
|
|
4443fa9e0e | ||
|
|
3056e2af11 | ||
|
|
7bfaf31138 | ||
|
|
f8d1945544 | ||
|
|
93d7f29d31 | ||
|
|
18ebd29032 | ||
|
|
fe56d6d708 | ||
|
|
14056cc584 | ||
|
|
d3ee7399eb | ||
|
|
5c54e9d059 | ||
|
|
ab6f856987 | ||
|
|
6e59657fe2 | ||
|
|
215f21439d | ||
|
|
53bf446502 | ||
|
|
2588279e52 | ||
|
|
f48dc250a9 | ||
|
|
15d9ceb4d1 | ||
|
|
a4a8e70f9c | ||
|
|
1d4dbd96ce | ||
|
|
476a2d57ca | ||
|
|
ce46a67d6d | ||
|
|
87e49dfb62 | ||
|
|
808bda67d4 | ||
|
|
c91a964513 | ||
|
|
e0a63a3f49 | ||
|
|
0b2e1bb7a2 | ||
|
|
7c4f739d07 | ||
|
|
3bd9400051 | ||
|
|
1caa5ccf0e | ||
|
|
76affeb6b2 | ||
|
|
a945291371 | ||
|
|
06c3fba96e | ||
|
|
7e739dd2f0 | ||
|
|
6c2f85d38a | ||
|
|
1ea1d15845 | ||
|
|
5e0d4d0f05 | ||
|
|
9a1d2e0d46 | ||
|
|
878f96a4b7 | ||
|
|
9efaab2beb | ||
|
|
2c34ba7eff | ||
|
|
57e2e22266 | ||
|
|
4ec5c15254 | ||
|
|
33a3683196 | ||
|
|
4be1ea3506 | ||
|
|
f86e63c851 | ||
|
|
f6cf73190f | ||
|
|
5308c87651 | ||
|
|
9bd5ffcd2d | ||
|
|
8d8acd5410 | ||
|
|
4d97c30a9c | ||
|
|
aa71ab0220 | ||
|
|
2345ee9856 | ||
|
|
65e9cdd444 | ||
|
|
85a2615db1 | ||
|
|
777615bc56 | ||
|
|
bac34ebd0c | ||
|
|
b2322e1c49 | ||
|
|
436671457d | ||
|
|
fc847093d2 | ||
|
|
d2a7af744d | ||
|
|
5acf557675 | ||
|
|
0b32cd2095 | ||
|
|
425b974331 | ||
|
|
db09dec855 | ||
|
|
20cdcdf917 | ||
|
|
796363f23c | ||
|
|
cb0df2169f | ||
|
|
95afc7f289 | ||
|
|
bc65c73c51 | ||
|
|
596bc561b8 | ||
|
|
f215fae6c7 | ||
|
|
5707c37b63 | ||
|
|
5780d3a8a4 | ||
|
|
ac18ff3e8b | ||
|
|
a9a720f3c3 | ||
|
|
4b4b721d49 | ||
|
|
6373d4097c | ||
|
|
dfe12ca644 | ||
|
|
2ef8a6fd20 | ||
|
|
27cfe1c4f3 | ||
|
|
ebd4622592 | ||
|
|
4fa30ab8cb | ||
|
|
1e6aaf451e | ||
|
|
bf65fbaca5 | ||
|
|
79dc05cb66 | ||
|
|
234985b57d | ||
|
|
d89bfb8e03 | ||
|
|
91a09d5d37 | ||
|
|
e87207eae3 | ||
|
|
3c33973269 | ||
|
|
3019377310 | ||
|
|
53def3a624 | ||
|
|
eb23e956cb | ||
|
|
d029d4d0bf | ||
|
|
4433095045 | ||
|
|
05c8117f7b | ||
|
|
3fbba6d540 | ||
|
|
439d956ac2 | ||
|
|
055cfae272 | ||
|
|
76b4369aa6 | ||
|
|
74c7738bc3 | ||
|
|
f6d75d507c | ||
|
|
6a1cd7bf87 | ||
|
|
57205e1149 | ||
|
|
ce4a8cce72 | ||
|
|
75085b3929 | ||
|
|
c489fcc63c | ||
|
|
e30ea89990 | ||
|
|
9ddcbd6de0 | ||
|
|
939645e144 | ||
|
|
1d413734d8 | ||
|
|
2e655d870c | ||
|
|
eea81723b8 | ||
|
|
bbdeedb44e | ||
|
|
3e64af910a | ||
|
|
fcdbf785bb | ||
|
|
7ebcba7c36 | ||
|
|
90d244fea8 | ||
|
|
497b9483f6 | ||
|
|
e33fd40033 | ||
|
|
2b93a1c089 | ||
|
|
15d4ef5518 | ||
|
|
2f5f515696 | ||
|
|
30bb33f1a0 | ||
|
|
7bff706aa0 | ||
|
|
b0cd752619 | ||
|
|
52dd0c71a5 | ||
|
|
2d292ed053 | ||
|
|
9770151583 | ||
|
|
89db50e8df | ||
|
|
3ac5992e1d | ||
|
|
8c4f21ff81 | ||
|
|
4de068c46e | ||
|
|
b6dfc2175f | ||
|
|
48d2239f5d | ||
|
|
06d988583d | ||
|
|
abea3e229e | ||
|
|
1c3f210848 | ||
|
|
722615cd9b | ||
|
|
809d1eca77 | ||
|
|
6e4439bf49 | ||
|
|
882f2f5d03 | ||
|
|
dde23b5549 | ||
|
|
c3ecd378f8 | ||
|
|
6da8e98c62 | ||
|
|
4d591f9efc | ||
|
|
26c3699a9e | ||
|
|
328ace1fd1 | ||
|
|
91bf135a4f | ||
|
|
e2b3260926 | ||
|
|
179289b504 | ||
|
|
bd8a843972 | ||
|
|
7e366ff980 | ||
|
|
d0c23fa840 | ||
|
|
4bc47af05b | ||
|
|
65c3e0a68d | ||
|
|
5c4f49b685 | ||
|
|
ba6290d616 | ||
|
|
6af7a9861d | ||
|
|
3b4bcb7932 | ||
|
|
0153e9f9e2 | ||
|
|
9a0b1a4ce3 | ||
|
|
e251dd950b | ||
|
|
40862df038 | ||
|
|
dc569175ef | ||
|
|
6ba98e4576 | ||
|
|
75b932b66e | ||
|
|
5aa85ab501 | ||
|
|
521ccf2026 | ||
|
|
e8b4dd0bd1 | ||
|
|
265042e2c8 | ||
|
|
1c85112d4f | ||
|
|
0fc5032b27 | ||
|
|
893ab1f9f5 | ||
|
|
a30ed600b6 | ||
|
|
a2c6cbe633 | ||
|
|
1f9c2c873d | ||
|
|
509c9eb7d3 | ||
|
|
fba03b3df7 | ||
|
|
c6d18f5d49 | ||
|
|
3e804d9ecc | ||
|
|
784d86d8e8 | ||
|
|
3bac1e61b9 | ||
|
|
6d2e100d63 | ||
|
|
cce81ecaad | ||
|
|
f8bf6bccd3 | ||
|
|
82fd9744bf | ||
|
|
cdff036935 | ||
|
|
458d282357 | ||
|
|
59074a275f | ||
|
|
92975eec51 | ||
|
|
e0d11a8abc | ||
|
|
fa004d63bb | ||
|
|
a217643ac9 | ||
|
|
fc3297c7b7 | ||
|
|
684f0d38ee | ||
|
|
332e7bca90 | ||
|
|
ae458d260f | ||
|
|
6b758c810e | ||
|
|
ffed906cef | ||
|
|
bcc504b8cc | ||
|
|
782f4cb20b | ||
|
|
aebd2ac232 | ||
|
|
2969b40ad4 | ||
|
|
e0045c7570 | ||
|
|
bc6f2fd49d | ||
|
|
cd25b973ea | ||
|
|
27bbbe7186 | ||
|
|
20cf98d42e | ||
|
|
63c4de512e | ||
|
|
337c15de35 | ||
|
|
2d361d3ee6 | ||
|
|
977fdf3cc3 | ||
|
|
9f6880b701 | ||
|
|
1a1afece7d | ||
|
|
08f233c5b1 | ||
|
|
9438654d99 | ||
|
|
60926f64cc | ||
|
|
b6b8286118 | ||
|
|
8f906cfd95 | ||
|
|
da03ed8ece | ||
|
|
7239cd3a6b | ||
|
|
3db07ca2d4 | ||
|
|
f8fe3c7af3 | ||
|
|
b6e9766f40 | ||
|
|
1f62e30a08 | ||
|
|
88f05ffd75 | ||
|
|
375bb385a2 | ||
|
|
51840e0366 | ||
|
|
831c581e0f | ||
|
|
e1734a1aa6 | ||
|
|
84376bb34c | ||
|
|
6d0b1dc1ee | ||
|
|
bc078472d4 | ||
|
|
8a273dbf63 | ||
|
|
001acfc6a5 |
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.23.2"
|
||||
go-version: "1.25.1"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -36,11 +36,17 @@ jobs:
|
||||
git diff --exit-code ||
|
||||
(echo "go.mod or go.sum has changed. Please run 'go mod tidy' and commit the changes." && exit 1)
|
||||
|
||||
- name: Generate docs
|
||||
run: |
|
||||
make cli-docs
|
||||
git diff --exit-code ||
|
||||
(echo "Documentation is out of date. Please run 'make cli-docs' and commit the changes." && exit 1)
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
make ucind-image
|
||||
make test
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
|
||||
check-protobuf:
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -57,7 +63,7 @@ jobs:
|
||||
- name: Install Mise
|
||||
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
|
||||
with:
|
||||
version: "2025.6.5"
|
||||
version: "2025.9.6"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.23.2"
|
||||
go-version: "1.25.1"
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||
with:
|
||||
version: v2.2.2
|
||||
version: v2.4.0
|
||||
|
||||
- name: Format code
|
||||
run: |
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/uncloud
|
||||
/uncloudd
|
||||
|
||||
# OS X
|
||||
.DS_Store
|
||||
@@ -37,3 +39,6 @@ node_modules/
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
.devcontainer/
|
||||
|
||||
.zed/
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@ builds:
|
||||
main: ./cmd/uncloud
|
||||
binary: uncloud
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
# Use CGO to be able to build fsevent required by compose on macOS.
|
||||
- CGO_ENABLED={{ if eq .Os "darwin" }}1{{ else }}0{{ end }}
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
|
||||
+76
-26
@@ -1,47 +1,97 @@
|
||||
[tools."aqua:vektra/mockery"]
|
||||
[[tools."aqua:vektra/mockery"]]
|
||||
version = "3.5.3"
|
||||
backend = "aqua:vektra/mockery"
|
||||
|
||||
[tools."aqua:vektra/mockery".checksums]
|
||||
"mockery_3.5.3_Darwin_arm64.tar.gz" = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
|
||||
"mockery_3.5.3_Linux_x86_64.tar.gz" = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
|
||||
[tools."aqua:vektra/mockery".platforms.linux-x64]
|
||||
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
|
||||
size = 5498859
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
|
||||
|
||||
[tools.go]
|
||||
version = "1.23.10"
|
||||
[tools."aqua:vektra/mockery".platforms.macos-arm64]
|
||||
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
|
||||
size = 5238877
|
||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz"
|
||||
|
||||
[[tools.go]]
|
||||
version = "1.25.1"
|
||||
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.go.platforms.linux-x64]
|
||||
checksum = "sha256:7716a0d940a0f6ae8e1f3b3f4f36299dc53e31b16840dbd171254312c41ca12e"
|
||||
size = 59660846
|
||||
url = "https://dl.google.com/go/go1.25.1.linux-amd64.tar.gz"
|
||||
|
||||
[tools.golangci-lint]
|
||||
version = "2.2.2"
|
||||
[tools.go.platforms.macos-arm64]
|
||||
checksum = "sha256:68deebb214f39d542e518ebb0598a406ab1b5a22bba8ec9ade9f55fb4dd94a6c"
|
||||
size = 57906702
|
||||
url = "https://dl.google.com/go/go1.25.1.darwin-arm64.tar.gz"
|
||||
|
||||
[[tools.golangci-lint]]
|
||||
version = "2.4.0"
|
||||
backend = "aqua:golangci/golangci-lint"
|
||||
|
||||
[tools.golangci-lint.checksums]
|
||||
"golangci-lint-2.2.2-darwin-arm64.tar.gz" = "sha256:d84d94d042c0d495fd1746f3d18948a75de163b17a14e8de3ef840928dd2df74"
|
||||
"golangci-lint-2.2.2-linux-amd64.tar.gz" = "sha256:c27fbde948a87d326feacd21df2f61a9c54dbd2e3bfa185c0a1cd6917a6f964f"
|
||||
[tools.golangci-lint.platforms.linux-x64]
|
||||
checksum = "sha256:fae792524c04424c0ac369f5b8076f04b45cf29fc945a370e55d369a8dc11840"
|
||||
size = 14126779
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-linux-amd64.tar.gz"
|
||||
|
||||
[tools.protoc]
|
||||
[tools.golangci-lint.platforms.macos-arm64]
|
||||
checksum = "sha256:cd4dd53fa09b6646baff5fd22b8c64d91db02c21c7496df27992d75d34feec59"
|
||||
size = 13542526
|
||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-darwin-arm64.tar.gz"
|
||||
|
||||
[[tools.node]]
|
||||
version = "22.20.0"
|
||||
backend = "core:node"
|
||||
|
||||
[tools.node.platforms.linux-x64]
|
||||
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
|
||||
size = 56645685
|
||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
|
||||
|
||||
[tools.node.platforms.macos-arm64]
|
||||
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
|
||||
size = 49838299
|
||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-darwin-arm64.tar.gz"
|
||||
|
||||
[[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.platforms.linux-x64]
|
||||
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
|
||||
size = 3235873
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
|
||||
|
||||
[tools.protoc-gen-go]
|
||||
[tools.protoc.platforms.macos-arm64]
|
||||
checksum = "blake3:650c2494c003e9fddd6f4164a0a36e31da51ae7e1e5208da8cb560a4fe886fcd"
|
||||
size = 2372763
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-aarch_64.zip"
|
||||
|
||||
[[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.platforms.linux-x64]
|
||||
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
|
||||
size = 1848331
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
|
||||
|
||||
[tools.protoc-gen-go-grpc]
|
||||
[tools.protoc-gen-go.platforms.macos-arm64]
|
||||
checksum = "blake3:c237256a7f718ac4ccdc037ee30025a0b7c3f79300d7831ec59d76d782443fd5"
|
||||
size = 1784028
|
||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
|
||||
|
||||
[[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"
|
||||
[tools.protoc-gen-go-grpc.platforms.linux-x64]
|
||||
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
|
||||
size = 4859466
|
||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
|
||||
|
||||
[tools.protoc-gen-go-grpc.platforms.macos-arm64]
|
||||
checksum = "blake3:3c668207565d23ea2cf006d699207e3a804ebb929a534bd38a3b4ff4e941e00c"
|
||||
size = 4565944
|
||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz"
|
||||
|
||||
+3
-2
@@ -3,8 +3,9 @@ experimental = true
|
||||
|
||||
[tools]
|
||||
"aqua:vektra/mockery" = "3.5.3"
|
||||
go = "1.23"
|
||||
golangci-lint = "2.2.2"
|
||||
go = "1.25.1"
|
||||
golangci-lint = "2.4.0"
|
||||
node = "22.20.0"
|
||||
protoc = "27.3"
|
||||
protoc-gen-go = "1.34.2"
|
||||
protoc-gen-go-grpc = "1.5.1"
|
||||
|
||||
+30
-9
@@ -1,10 +1,14 @@
|
||||
# AI.md - Uncloud Project Guide
|
||||
# AGENTS.md - Uncloud Project Guide
|
||||
|
||||
This document provides comprehensive information about the Uncloud project for AI assistants to understand the codebase, architecture, and development practices.
|
||||
This document provides comprehensive information about the Uncloud project for AI assistants to understand the codebase,
|
||||
architecture, and development practices.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Uncloud** is a lightweight clustering and container orchestration tool that enables deployment and management of web applications across cloud VMs and bare metal servers. It creates a secure WireGuard mesh network between Docker hosts and provides automatic service discovery, load balancing, HTTPS ingress, and simple CLI commands for application management.
|
||||
**Uncloud** is a lightweight clustering and container orchestration tool that enables deployment and management of web
|
||||
applications across cloud VMs and bare metal servers. It creates a secure WireGuard mesh network between Docker hosts
|
||||
and provides automatic service discovery, load balancing, HTTPS ingress, and simple CLI commands for application
|
||||
management.
|
||||
|
||||
### Key Characteristics
|
||||
|
||||
@@ -28,7 +32,7 @@ This document provides comprehensive information about the Uncloud project for A
|
||||
- **Docker Compose compatibility**: Uses familiar Docker Compose format
|
||||
- **Zero-downtime deployments**: Rolling updates without service interruption
|
||||
- **Automatic HTTPS**: Caddy reverse proxy with Let's Encrypt integration
|
||||
- **Managed DNS**: Free `*.cluster.uncloud.run` subdomains via Uncloud DNS service
|
||||
- **Managed DNS**: Free `*.xxxxxx.uncld.dev` subdomains via Uncloud DNS service
|
||||
- **Cross-machine scaling**: Run containers across multiple machines
|
||||
|
||||
### 🔧 Developer Experience
|
||||
@@ -88,6 +92,10 @@ This document provides comprehensive information about the Uncloud project for A
|
||||
- **`scripts/`**: Installation and utility scripts
|
||||
- **`test/`**: Test suites and test infrastructure
|
||||
- **`website/`**: Documentation website (Docusaurus)
|
||||
|
||||
- `landing/`: Landing page
|
||||
- `docs/`: User documentation
|
||||
|
||||
- **`misc/`**: Design documents and guides
|
||||
|
||||
## Key Technologies
|
||||
@@ -184,9 +192,11 @@ uc context use <name> # Switch context
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit tests alongside source files (`*_test.go`)
|
||||
- Integration tests in `test/e2e/`
|
||||
- Test fixtures in `test/fixtures/`
|
||||
- Test files and locations
|
||||
- Unit tests alongside source files (`*_test.go`)
|
||||
- Integration tests in `test/e2e/`
|
||||
- Test fixtures in `test/fixtures/`
|
||||
- Use table driven tests whenever possible
|
||||
|
||||
### Dependencies
|
||||
|
||||
@@ -233,7 +243,7 @@ uc context use <name> # Switch context
|
||||
- `internal/machine/machine.go`: Core machine management
|
||||
- `pkg/api/`: Public API definitions
|
||||
- `misc/design.md`: Architecture and design philosophy
|
||||
- `README.md`: User-facing documentation
|
||||
- `README.md`: Repository README
|
||||
|
||||
### Configuration Files
|
||||
|
||||
@@ -241,4 +251,15 @@ uc context use <name> # Switch context
|
||||
- `Makefile`: Build and development tasks
|
||||
- `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing)
|
||||
|
||||
This document should help AI assistants understand the project structure, make informed suggestions, and contribute effectively to the Uncloud codebase.
|
||||
This document should help AI assistants understand the project structure, make informed suggestions, and contribute
|
||||
effectively to the Uncloud codebase.
|
||||
|
||||
## Documentation
|
||||
|
||||
Instructions when generating documentation:
|
||||
|
||||
- Use conversational language – write as if you were speaking to a friend.
|
||||
- Keep sentences simple, optimize for clarity and understanding.
|
||||
- Do not use em dashes (—) or semicolons (;) in sentences. Instead, break complex sentences into simpler ones.
|
||||
- Place the subject before the action whenever possible. Example: prefer "The function loads data" over "Data is loaded
|
||||
by the function."
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
ARG ALPINE_VERSION=3.20.3
|
||||
|
||||
FROM golang:1.23.2-alpine AS uncloudd
|
||||
FROM golang:1.25.1-alpine AS uncloudd
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -13,7 +13,7 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download && go mod verify
|
||||
|
||||
COPY . .
|
||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd cmd/uncloudd/main.go
|
||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd ./cmd/uncloudd
|
||||
|
||||
|
||||
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
||||
|
||||
@@ -38,7 +38,7 @@ demo-reset:
|
||||
|
||||
.PHONY: ucind-cluster
|
||||
ucind-cluster:
|
||||
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
|
||||
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
|
||||
|
||||
.PHONY: proto
|
||||
proto:
|
||||
@@ -73,14 +73,14 @@ mocks:
|
||||
.PHONY: test
|
||||
test:
|
||||
ifeq ($(TEST_NAME),)
|
||||
go test -count=1 -v ./...
|
||||
go test -shuffle=on -count=1 -v ./...
|
||||
else
|
||||
go test -race -count=1 -v -run ^$(TEST_NAME)$$ ./...
|
||||
go test -shuffle=on -race -count=1 -v -run ^$(TEST_NAME)$$ ./...
|
||||
endif
|
||||
|
||||
.PHONY: test-e2e
|
||||
test-e2e:
|
||||
go test -race -count=1 -v ./test/e2e
|
||||
go test -shuffle=on -race -count=1 -v ./test/e2e
|
||||
|
||||
.PHONY: test-clean
|
||||
test-clean:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
<img src="./website/landing/images/logo.svg" height="100" width="100" alt="Uncloud logo"/>
|
||||
<h1>Uncloud</h1>
|
||||
<p><strong>Docker simplicity. Multi-machine power.</strong></p>
|
||||
<img src="./website/landing/images/logo-title.svg#gh-light-mode-only" alt="Uncloud logo"/>
|
||||
<img src="./website/landing/images/logo-title-dark.svg#gh-dark-mode-only" alt="Uncloud logo"/>
|
||||
<p><strong>▸ Deploy and scale containerised apps across servers without Swarm or Kubernetes overhead ◂</strong></p>
|
||||
|
||||
<p>
|
||||
<a href="https://uncloud.run/docs"><img src="https://img.shields.io/badge/Docs-blue.svg?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation"></a>
|
||||
@@ -25,12 +25,14 @@ complexity of Kubernetes.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment —
|
||||
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment,
|
||||
regardless of location or provider.
|
||||
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
|
||||
need to learn a new bespoke DSL.
|
||||
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
|
||||
soon.
|
||||
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Build and push your Docker images directly to
|
||||
your machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
|
||||
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
||||
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
|
||||
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
|
||||
@@ -38,18 +40,13 @@ complexity of Kubernetes.
|
||||
* **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
|
||||
* **Managed DNS**: Automatic DNS records `*.xxxxxx.uncld.dev` for services with public access via managed
|
||||
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
||||
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
||||
Encrypt.
|
||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
||||
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
||||
|
||||
### 🚀 Coming soon
|
||||
|
||||
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Push your Docker images directly to your
|
||||
machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
|
||||
|
||||
## 🎬 Quick demo
|
||||
|
||||
The screenshot below demonstrates how I use Uncloud to deploy https://uncloud.run website to 2 remote machines from
|
||||
@@ -64,9 +61,15 @@ Here is a more advanced use case. Deploy a highly available web app with automat
|
||||
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" />
|
||||
<img src="https://embed-ssl.wistia.com/deliveries/4fb71ea442905b81b1ad4e25fc02132e.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
||||
</a>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
> **📚 Want more examples?** Check out the [**uncloud-recipes**](https://github.com/psviderski/uncloud-recipes)
|
||||
> repository for community recipes and templates for deploying popular services on Uncloud.
|
||||
|
||||
## 💫 Why Uncloud?
|
||||
|
||||
Modern cloud platforms like Heroku and Render offer amazing developer experiences but at a premium price. Traditional
|
||||
@@ -278,6 +281,16 @@ using the CLI or directly over SSH. They all have the complete cluster state and
|
||||
machine is a full backup of your control plane.
|
||||
</details>
|
||||
|
||||
## 🧪 Interactive tutorials
|
||||
|
||||
To give you a chance to play with Uncloud without even leaving your browser, we're providing interactive tutorials and playgrounds on the [iximiuz Labs](https://labs.iximiuz.com/) platform.
|
||||
|
||||
Available tutorials:
|
||||
|
||||
1. [Setting up a new Uncloud cluster](https://labs.iximiuz.com/tutorials/uncloud-create-cluster-ebebf72b) - the tutorial walks you through creating a new cluster with two machines and then deploying a simple web service to it.
|
||||
|
||||
You can also launch the [Uncloud playground](https://labs.iximiuz.com/playgrounds/uncloud-cluster-64523f7c) where you can play with an already initialized Uncloud cluster.
|
||||
|
||||
## 🏗 Project status
|
||||
|
||||
Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
|
||||
@@ -334,6 +347,10 @@ These companies and projects are helping Uncloud with their generous sponsorship
|
||||
|
||||
## ❤️ Contributors
|
||||
|
||||
<a href="https://trendshift.io/repositories/14069">
|
||||
<img alt="Trendshift" src="https://trendshift.io/api/badge/repositories/14069" />
|
||||
</a>
|
||||
|
||||
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
|
||||
|
||||
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
|
||||
|
||||
+67
-33
@@ -10,68 +10,102 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewBuildCommand creates a new command to build services from a Compose file.
|
||||
type buildOptions struct {
|
||||
cli.BuildServicesOptions
|
||||
files []string
|
||||
profiles []string
|
||||
}
|
||||
|
||||
// NewBuildCommand creates a new command to build images for services from a Compose file.
|
||||
func NewBuildCommand() *cobra.Command {
|
||||
opts := cli.BuildOptions{}
|
||||
opts := buildOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "build [FLAGS] [SERVICE...]",
|
||||
Short: "Build services from a Compose file.",
|
||||
Long: `Build images for services from a Compose file using local Docker.
|
||||
|
||||
By default, built images remain on the local Docker host. Use --push to upload them
|
||||
to cluster machines or --push-registry to upload them to external registries.`,
|
||||
Example: ` # Build all services that have a build section in compose.yaml.
|
||||
uc build
|
||||
|
||||
# Build specific services that have a build section.
|
||||
uc build web api
|
||||
|
||||
# Build services and push images to all cluster machines or service x-machines if specified.
|
||||
uc build --push
|
||||
|
||||
# Build services and push images to specific machines.
|
||||
uc build --push -m machine1,machine2
|
||||
|
||||
# Build services and push images to external registries (e.g., Docker Hub).
|
||||
uc build --push-registry
|
||||
|
||||
# Build services with build arguments, pull newer base images before building, and don't use cache.
|
||||
uc build --build-arg NODE_VERSION=24 --build-arg ENV=production --no-cache --pull`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
if len(args) > 0 {
|
||||
opts.Services = args
|
||||
}
|
||||
|
||||
return runBuild(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: "service",
|
||||
}
|
||||
|
||||
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,
|
||||
cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil,
|
||||
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
|
||||
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false,
|
||||
"Check the build configuration for services without building them.")
|
||||
cmd.Flags().BoolVar(&opts.Deps, "deps", false,
|
||||
"Also build services declared as dependencies of the selected services.")
|
||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||
"One or more Compose files to build. (default compose.yaml)")
|
||||
cmd.Flags().StringSliceVarP(&opts.Machines, "machine", "m", nil,
|
||||
"Machine names or IDs to push the built images to (requires --push).\n"+
|
||||
"Can be specified multiple times or as a comma-separated list. (default is all machines or x-machines)")
|
||||
cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false,
|
||||
"Do not use cache when building images.")
|
||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||
"One or more Compose profiles to enable.")
|
||||
cmd.Flags().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)")
|
||||
cmd.Flags().BoolVar(&opts.Pull, "pull", false,
|
||||
"Always attempt to pull newer versions of base images before building.")
|
||||
cmd.Flags().BoolVar(&opts.PushCluster, "push", false,
|
||||
"Upload the built images to cluster machines after building.\n"+
|
||||
"Use --machine to specify which machines. (default is all machines)")
|
||||
cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false,
|
||||
"Upload the built images to external registries (e.g., Docker Hub) after building.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// 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...))
|
||||
// runBuild parses the Compose file(s) and builds the images for selected services.
|
||||
func runBuild(ctx context.Context, uncli *cli.CLI, opts buildOptions) error {
|
||||
// Validate push flags.
|
||||
if opts.PushCluster && opts.PushRegistry {
|
||||
return fmt.Errorf("cannot specify both --push and --push-registry: choose one push target")
|
||||
}
|
||||
|
||||
return projectOpts
|
||||
}
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.Machines)
|
||||
// Special handling for an explicit "all" keyword to push to all machines.
|
||||
if len(machines) == 1 && machines[0] == "all" {
|
||||
machines = nil
|
||||
}
|
||||
opts.Machines = machines
|
||||
|
||||
// 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...)
|
||||
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load compose file(s): %w", err)
|
||||
}
|
||||
|
||||
if len(opts.Services) > 0 {
|
||||
project, err = project.WithSelectedServices(opts.Services)
|
||||
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select services: %w", err)
|
||||
return fmt.Errorf("determine services to build: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
||||
|
||||
if len(servicesToBuild) == 0 {
|
||||
fmt.Println("No services to build.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return cli.BuildServices(ctx, servicesToBuild, opts)
|
||||
return uncli.BuildServices(ctx, project, opts.BuildServicesOptions)
|
||||
}
|
||||
|
||||
@@ -7,14 +7,12 @@ import (
|
||||
|
||||
"github.com/alecthomas/chroma/v2/quick"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type configOptions struct {
|
||||
machine string
|
||||
noColor bool
|
||||
context string
|
||||
}
|
||||
|
||||
func NewConfigCommand() *cobra.Command {
|
||||
@@ -34,16 +32,12 @@ func NewConfigCommand() *cobra.Command {
|
||||
"Name or ID of the machine to get the configuration from. (default is connected machine)")
|
||||
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
|
||||
"Disable syntax highlighting for the output.")
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
@@ -51,7 +45,7 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
||||
|
||||
if opts.machine != "" {
|
||||
// If a specific machine is requested, use it to get the Caddy configuration.
|
||||
ctx, _, err = api.ProxyMachinesContext(ctx, clusterClient, []string{opts.machine})
|
||||
ctx, _, err = clusterClient.ProxyMachinesContext(ctx, []string{opts.machine})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ type deployOptions struct {
|
||||
caddyfile string
|
||||
image string
|
||||
machines []string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewDeployCommand() *cobra.Command {
|
||||
@@ -44,12 +43,8 @@ func NewDeployCommand() *cobra.Command {
|
||||
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)",
|
||||
)
|
||||
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||
"list. (default is all machines)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -64,7 +59,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
caddyfile = strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewConnectionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "connection",
|
||||
Aliases: []string{"conn"},
|
||||
Short: "Choose a new default connection for the current context.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return selectConnection(uncli)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func selectConnection(uncli *cli.CLI) error {
|
||||
if uncli.Config == nil {
|
||||
return fmt.Errorf("connection management is not available: Uncloud configuration file is not being used")
|
||||
}
|
||||
if len(uncli.Config.Contexts) == 0 {
|
||||
return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path())
|
||||
}
|
||||
|
||||
currentCtxName := uncli.Config.CurrentContext
|
||||
currentCtx, ok := uncli.Config.Contexts[currentCtxName]
|
||||
if !ok {
|
||||
return fmt.Errorf("current context '%s' not found", currentCtxName)
|
||||
}
|
||||
|
||||
if len(currentCtx.Connections) == 0 {
|
||||
return fmt.Errorf("no connections found in context '%s'", currentCtxName)
|
||||
}
|
||||
|
||||
var selectedConnection int
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select a default connection").
|
||||
Options(buildConnectionOptions(currentCtx.Connections)...).
|
||||
Value(&selectedConnection),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("select connection: %w", err)
|
||||
}
|
||||
|
||||
currentCtx.SetDefaultConnection(selectedConnection)
|
||||
selected := currentCtx.Connections[0]
|
||||
|
||||
if err := uncli.Config.Save(); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Default connection for context '%s' is now '%s'.\n", currentCtxName, selected.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildConnectionOptions(connections []config.MachineConnection) []huh.Option[int] {
|
||||
options := make([]huh.Option[int], len(connections))
|
||||
for i, conn := range connections {
|
||||
key := conn.String()
|
||||
opt := huh.NewOption(key, i)
|
||||
if i == 0 {
|
||||
opt.Key += " (default)"
|
||||
opt = opt.Selected(true)
|
||||
}
|
||||
options[i] = opt
|
||||
}
|
||||
return options
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
var clusterContext string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
@@ -24,11 +22,6 @@ func NewListCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&clusterContext, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -51,7 +44,7 @@ func list(uncli *cli.CLI) error {
|
||||
for _, name := range contextNames {
|
||||
current := ""
|
||||
if name == currentContext {
|
||||
current = "*"
|
||||
current = "✓"
|
||||
}
|
||||
connCount := len(uncli.Config.Contexts[name].Connections)
|
||||
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
||||
|
||||
@@ -19,6 +19,7 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
NewListCommand(),
|
||||
NewUseCommand(),
|
||||
NewConnectionCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
+86
-35
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
@@ -13,17 +14,20 @@ import (
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type deployOptions struct {
|
||||
cli.BuildServicesOptions
|
||||
|
||||
files []string
|
||||
profiles []string
|
||||
services []string
|
||||
noBuild bool
|
||||
recreate bool
|
||||
|
||||
context string
|
||||
skipHealth bool
|
||||
yes bool
|
||||
}
|
||||
|
||||
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
||||
@@ -33,26 +37,38 @@ func NewDeployCommand() *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)
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
if len(args) > 0 {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.services = args
|
||||
}
|
||||
|
||||
return runDeploy(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: "service",
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil,
|
||||
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
|
||||
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
|
||||
cmd.Flags().BoolVar(&opts.BuildServicesOptions.Pull, "build-pull", false,
|
||||
"Always attempt to pull newer versions of base images before building service images.")
|
||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||
"One or more Compose files to deploy services from. (default compose.yaml)")
|
||||
cmd.Flags().BoolVar(&opts.noBuild, "no-build", false,
|
||||
"Do not build new images before deploying services.")
|
||||
cmd.Flags().BoolVar(&opts.BuildServicesOptions.NoCache, "no-cache", false,
|
||||
"Do not use cache when building images.")
|
||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||
"One or more Compose profiles to enable.")
|
||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context to deploy to (default is the current context)")
|
||||
cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false,
|
||||
"Do not build images before deploying services. (default false)")
|
||||
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
||||
"Recreate containers even if their configuration and image haven't changed.")
|
||||
cmd.Flags().BoolVar(&opts.skipHealth, "skip-health", false,
|
||||
"Skip the monitoring period and health checks after starting new containers. Useful for faster emergency "+
|
||||
"deployments.\n"+
|
||||
"Warning: This may cause downtime if new containers fail to start properly.")
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+
|
||||
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
||||
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
||||
@@ -60,22 +76,9 @@ func NewDeployCommand() *cobra.Command {
|
||||
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...)
|
||||
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load compose file(s): %w", err)
|
||||
}
|
||||
@@ -88,32 +91,72 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
||||
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.services, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("determine services to build: %w", err)
|
||||
}
|
||||
|
||||
if len(servicesToBuild) > 0 {
|
||||
if opts.noBuild {
|
||||
fmt.Println("Not building services as requested.")
|
||||
} else {
|
||||
buildOpts := cli.BuildOptions{
|
||||
Push: true,
|
||||
NoCache: false,
|
||||
}
|
||||
// Build service images without pushing them to cluster yet to not connect to the cluster twice.
|
||||
opts.BuildServicesOptions.Deps = true // build dependencies as deploy includes them by default
|
||||
opts.BuildServicesOptions.Services = opts.services
|
||||
|
||||
if err := cli.BuildServices(ctx, servicesToBuild, buildOpts); err != nil {
|
||||
if err = uncli.BuildServices(ctx, project, opts.BuildServicesOptions); err != nil {
|
||||
return fmt.Errorf("build services: %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
var strategy deploy.Strategy
|
||||
if opts.recreate {
|
||||
strategy = &deploy.RollingStrategy{ForceRecreate: true}
|
||||
if len(servicesToBuild) > 0 && !opts.noBuild {
|
||||
// Push built service images to cluster machines one at a time.
|
||||
var errs []error
|
||||
for _, s := range servicesToBuild {
|
||||
if s.Image == "" {
|
||||
// Skip services without an image name (shouldn't happen for services with build config).
|
||||
continue
|
||||
}
|
||||
|
||||
// Push to the specified x-machines or to *all* cluster machines if not specified.
|
||||
var pushOpts client.PushImageOptions
|
||||
if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
|
||||
pushOpts.Machines = machines
|
||||
}
|
||||
if len(pushOpts.Machines) == 0 {
|
||||
pushOpts.AllMachines = true
|
||||
}
|
||||
|
||||
boldStyle := lipgloss.NewStyle().Bold(true)
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
|
||||
return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
|
||||
// Collect errors to try pushing all images.
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = errors.Join(errs...); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
strategy := &deploy.RollingStrategy{
|
||||
ForceRecreate: opts.recreate,
|
||||
SkipHealthMonitor: opts.skipHealth,
|
||||
}
|
||||
composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy)
|
||||
if err != nil {
|
||||
@@ -130,12 +173,19 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Deployment plan:")
|
||||
fmt.Println(lipgloss.NewStyle().Bold(true).Render("Deployment plan"))
|
||||
if err = printPlan(ctx, clusterClient, plan); err != nil {
|
||||
return fmt.Errorf("print deployment plan: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
|
||||
if !opts.yes {
|
||||
if !cli.IsStdinTerminal() {
|
||||
return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " +
|
||||
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
|
||||
}
|
||||
|
||||
confirmed, err := cli.Confirm()
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm deployment: %w", err)
|
||||
@@ -144,6 +194,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
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 {
|
||||
@@ -153,7 +204,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
}, uncli.ProgressOut(), "Deploying services")
|
||||
}
|
||||
|
||||
func printPlan(ctx context.Context, cli *client.Client, plan deploy.SequenceOperation) error {
|
||||
func printPlan(ctx context.Context, cli *client.Client, plan operation.SequenceOperation) error {
|
||||
for _, op := range plan.Operations {
|
||||
svcPlan, ok := op.(*deploy.Plan)
|
||||
if !ok {
|
||||
|
||||
@@ -12,32 +12,21 @@ import (
|
||||
"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)
|
||||
return release(cmd.Context(), uncli)
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
func release(ctx context.Context, uncli *cli.CLI) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
|
||||
|
||||
type reserveOptions struct {
|
||||
endpoint string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewReserveCommand() *cobra.Command {
|
||||
@@ -36,16 +35,12 @@ func NewReserveCommand() *cobra.Command {
|
||||
|
||||
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)
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ func NewRootCommand() *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 " +
|
||||
"DNS commands allow you to reserve or release a unique 'xxxxxx.uncld.dev' domain for your " +
|
||||
"cluster. When reserved, Caddy service deployments will automatically update DNS records to route " +
|
||||
"traffic to the services in the cluster.",
|
||||
}
|
||||
|
||||
+3
-14
@@ -10,32 +10,21 @@ import (
|
||||
"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)
|
||||
return show(cmd.Context(), uncli)
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
func show(ctx context.Context, uncli *cli.CLI) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listOptions struct {
|
||||
machines []string
|
||||
nameFilter string
|
||||
}
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
opts := listOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls [REPO:[TAG]]",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List images on machines in the cluster.",
|
||||
Long: "List images on machines in the cluster. By default, on all machines. Optionally filter by image name.",
|
||||
Example: ` # List all images on all machines.
|
||||
uc image ls
|
||||
|
||||
# List images on specific machine.
|
||||
uc image ls -m machine1
|
||||
|
||||
# List images on multiple machines.
|
||||
uc image ls -m machine1,machine2
|
||||
|
||||
# List images filtered by name (with any tag) on all machines.
|
||||
uc image ls myapp
|
||||
|
||||
# List images filtered by name pattern on specific machine.
|
||||
uc image ls "myapp:1.*" -m machine1`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.nameFilter = args[0]
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
|
||||
"(default is include all machines)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// imageRow represents a single image with its metadata for display.
|
||||
type imageRow struct {
|
||||
id string
|
||||
name string
|
||||
platforms string
|
||||
createdHuman string
|
||||
createdUnix int64
|
||||
size string
|
||||
inUse string
|
||||
store string
|
||||
machine string
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
// Get all machines to create ID to name mapping.
|
||||
allMachines, err := clusterClient.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
|
||||
machineIDToName := make(map[string]string)
|
||||
for _, machineMember := range allMachines {
|
||||
if machineMember.Machine != nil && machineMember.Machine.Id != "" && machineMember.Machine.Name != "" {
|
||||
machineIDToName[machineMember.Machine.Id] = machineMember.Machine.Name
|
||||
}
|
||||
}
|
||||
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
|
||||
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
|
||||
Machines: machines,
|
||||
Name: opts.nameFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list images: %w", err)
|
||||
}
|
||||
|
||||
// Collect all images from all machines.
|
||||
var rows []imageRow
|
||||
|
||||
for _, machineImages := range clusterImages {
|
||||
// Get machine name for better readability.
|
||||
machineName := machineImages.Metadata.Machine
|
||||
if m := allMachines.FindByNameOrID(machineName); m != nil {
|
||||
machineName = m.Machine.Name
|
||||
}
|
||||
|
||||
store := "docker"
|
||||
if machineImages.ContainerdStore {
|
||||
store = "containerd"
|
||||
}
|
||||
|
||||
// Process each image for this machine.
|
||||
for _, img := range machineImages.Images {
|
||||
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
|
||||
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
|
||||
|
||||
name := "<none>"
|
||||
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
|
||||
name = img.RepoTags[0]
|
||||
}
|
||||
|
||||
imgPlatforms, _ := imagePlatforms(img)
|
||||
formattedPlatforms := formatPlatforms(imgPlatforms)
|
||||
|
||||
created := ""
|
||||
createdAt := time.Unix(img.Created, 0)
|
||||
if !createdAt.IsZero() {
|
||||
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
||||
}
|
||||
|
||||
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
|
||||
|
||||
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
|
||||
inUse := "-"
|
||||
if img.Containers != -1 { // -1 means the info is not available.
|
||||
if img.Containers > 0 {
|
||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
|
||||
} else {
|
||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, imageRow{
|
||||
id: id,
|
||||
name: name,
|
||||
platforms: formattedPlatforms,
|
||||
createdHuman: created,
|
||||
createdUnix: img.Created,
|
||||
size: size,
|
||||
inUse: inUse,
|
||||
store: store,
|
||||
machine: machineName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if opts.nameFilter != "" {
|
||||
fmt.Printf("No images matching '%s' found.\n", opts.nameFilter)
|
||||
} else {
|
||||
fmt.Println("No images found.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort images by name, then by machine name.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].name != rows[j].name {
|
||||
return rows[i].name < rows[j].name
|
||||
}
|
||||
return rows[i].machine < rows[j].machine
|
||||
})
|
||||
|
||||
// Print the images in a table format.
|
||||
fmt.Println(formatImageTable(rows))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
|
||||
func imagePlatforms(img image.Summary) ([]string, bool) {
|
||||
var formattedPlatforms []string
|
||||
multiPlatform := false
|
||||
|
||||
for _, m := range img.Manifests {
|
||||
if m.Kind != image.ManifestKindImage || !m.Available {
|
||||
continue
|
||||
}
|
||||
|
||||
if m.ID != img.ID {
|
||||
// There is an image manifest that has digest different from the main image digest.
|
||||
// This means the image manifest is an index or a manifest list (multi-platform image).
|
||||
multiPlatform = true
|
||||
}
|
||||
formattedPlatforms = append(formattedPlatforms, platforms.Format(m.ImageData.Platform))
|
||||
}
|
||||
|
||||
slices.Sort(formattedPlatforms)
|
||||
|
||||
return formattedPlatforms, multiPlatform
|
||||
}
|
||||
|
||||
func formatPlatforms(platforms []string) string {
|
||||
if len(platforms) == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
platformStyle := lipgloss.NewStyle().
|
||||
BorderForeground(lipgloss.Color("152")).
|
||||
Foreground(lipgloss.Color("0")).
|
||||
Background(lipgloss.Color("152"))
|
||||
// Use fancy pill borders only if the output is a terminal with color support.
|
||||
if lipgloss.ColorProfile() != termenv.Ascii {
|
||||
platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true)
|
||||
}
|
||||
|
||||
styledPlatforms := make([]string, len(platforms))
|
||||
for i, p := range platforms {
|
||||
styledPlatforms[i] = platformStyle.Render(p)
|
||||
}
|
||||
|
||||
return strings.Join(styledPlatforms, " ")
|
||||
}
|
||||
|
||||
func formatImageTable(rows []imageRow) string {
|
||||
columns := []struct {
|
||||
name string
|
||||
hide bool
|
||||
}{
|
||||
{name: "IMAGE ID"},
|
||||
{name: "NAME"},
|
||||
{name: "PLATFORMS"},
|
||||
{name: "CREATED"},
|
||||
{name: "SIZE"},
|
||||
{name: "IN USE"},
|
||||
{name: "STORE"},
|
||||
{name: "MACHINE"},
|
||||
}
|
||||
|
||||
// Hide the "IN USE" column if none of the images have that info available.
|
||||
inUseInfoAvailable := slices.ContainsFunc(rows, func(r imageRow) bool {
|
||||
return r.inUse != "-"
|
||||
})
|
||||
if !inUseInfoAvailable {
|
||||
// Hide "IN USE" column.
|
||||
columns[5].hide = true
|
||||
}
|
||||
|
||||
t := table.New().
|
||||
// Remove the default border.
|
||||
Border(lipgloss.Border{}).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderLeft(false).
|
||||
BorderRight(false).
|
||||
BorderHeader(false).
|
||||
BorderColumn(false).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
if row == table.HeaderRow {
|
||||
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
|
||||
}
|
||||
// Regular style for data rows with padding.
|
||||
return lipgloss.NewStyle().PaddingRight(3)
|
||||
})
|
||||
|
||||
var headers []string
|
||||
for _, col := range columns {
|
||||
if !col.hide {
|
||||
headers = append(headers, col.name)
|
||||
}
|
||||
}
|
||||
t.Headers(headers...)
|
||||
|
||||
for _, row := range rows {
|
||||
values := []string{
|
||||
row.id,
|
||||
row.name,
|
||||
row.platforms,
|
||||
row.createdHuman,
|
||||
row.size,
|
||||
row.inUse,
|
||||
row.store,
|
||||
row.machine,
|
||||
}
|
||||
var filteredValues []string
|
||||
for i, v := range values {
|
||||
if !columns[i].hide {
|
||||
filteredValues = append(filteredValues, v)
|
||||
}
|
||||
}
|
||||
t.Row(filteredValues...)
|
||||
}
|
||||
|
||||
return t.String()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type pushOptions struct {
|
||||
image string
|
||||
machines []string
|
||||
platform string
|
||||
}
|
||||
|
||||
func NewPushCommand() *cobra.Command {
|
||||
opts := pushOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "push IMAGE",
|
||||
Short: "Upload a local Docker image to the cluster.",
|
||||
Long: `Upload a local Docker image to the cluster transferring only the missing layers.
|
||||
The image is uploaded to all cluster machines (default) or the specified machine(s).`,
|
||||
Example: ` # Push image to all machines in the cluster.
|
||||
uc image push myapp:latest
|
||||
|
||||
# Push image to specific machine.
|
||||
uc image push myapp:latest -m machine1
|
||||
|
||||
# Push image to multiple machines.
|
||||
uc image push myapp:latest -m machine1,machine2,machine3
|
||||
|
||||
# Push a specific platform of a multi-platform image.
|
||||
uc image push myapp:latest --platform linux/amd64`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
opts.image = args[0]
|
||||
return push(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||
"Machine names or IDs to push the image to. Can be specified multiple times or as a comma-separated list. "+
|
||||
"(default is all machines)")
|
||||
cmd.Flags().StringVar(
|
||||
&opts.platform, "platform", "",
|
||||
"Push a specific platform of a multi-platform image (e.g., linux/amd64, linux/arm64).\n"+
|
||||
"Local Docker must be configured to use containerd image store to support multi-platform images.",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||
pushOpts := client.PushImageOptions{}
|
||||
|
||||
// Special handling for an explicit "all" keyword to push to all machines.
|
||||
if len(machines) == 1 && machines[0] == "all" {
|
||||
pushOpts.AllMachines = true
|
||||
} else if len(machines) > 0 {
|
||||
pushOpts.Machines = machines
|
||||
} else {
|
||||
// Default is to push to all machines in the cluster.
|
||||
pushOpts.AllMachines = true
|
||||
}
|
||||
|
||||
if opts.platform != "" {
|
||||
p, err := platforms.Parse(opts.platform)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid platform '%s': %w", opts.platform, err)
|
||||
}
|
||||
pushOpts.Platform = &p
|
||||
}
|
||||
|
||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err = clusterClient.PushImage(ctx, opts.image, pushOpts); err != nil {
|
||||
return fmt.Errorf("push image to cluster: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", opts.image))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "image",
|
||||
Short: "Manage images on machines in the cluster.",
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
NewListCommand(),
|
||||
NewPushCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/image"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewImagesCommand returns the 'image ls' command modified to work as 'images'.
|
||||
func NewImagesCommand() *cobra.Command {
|
||||
listCmd := image.NewListCommand()
|
||||
listCmd.Use = "images [IMAGE]"
|
||||
// Remove 'list' alias since this command is already an alias.
|
||||
listCmd.Aliases = nil
|
||||
listCmd.Example = strings.ReplaceAll(listCmd.Example, "uc image ls", "uc images")
|
||||
|
||||
return listCmd
|
||||
}
|
||||
+103
-45
@@ -5,27 +5,30 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/charmbracelet/huh/spinner"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"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/internal/machine/network"
|
||||
"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"
|
||||
)
|
||||
|
||||
type addOptions struct {
|
||||
name string
|
||||
noCaddy bool
|
||||
noInstall bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
context string
|
||||
version string
|
||||
wgEndpoints []string
|
||||
yes bool
|
||||
}
|
||||
|
||||
func NewAddCommand() *cobra.Command {
|
||||
@@ -33,11 +36,24 @@ func NewAddCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add [USER@]HOST[:PORT]",
|
||||
Short: "Add a remote machine to a cluster.",
|
||||
Long: `Add a new machine to an existing Uncloud cluster.
|
||||
|
||||
Connection methods:
|
||||
ssh://user@host - Use built-in SSH library (default, no prefix required)
|
||||
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
user, host, port, err := config.SSHDestination(args[0]).Parse()
|
||||
// Determine if SSH CLI needs to be used and strip scheme
|
||||
destination := args[0]
|
||||
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
|
||||
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
||||
destination = strings.TrimPrefix(destination, "ssh://")
|
||||
|
||||
user, host, port, err := config.SSHDestination(destination).Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse remote machine: %w", err)
|
||||
}
|
||||
@@ -46,6 +62,7 @@ func NewAddCommand() *cobra.Command {
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
UseSSHCLI: useSSHCLI,
|
||||
}
|
||||
|
||||
return add(cmd.Context(), uncli, remoteMachine, opts)
|
||||
@@ -56,6 +73,11 @@ func NewAddCommand() *cobra.Command {
|
||||
&opts.noCaddy, "no-caddy", false,
|
||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noInstall, "no-install", false,
|
||||
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
||||
"Assumes they're already installed and running.",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "auto",
|
||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||
@@ -70,10 +92,17 @@ func NewAddCommand() *cobra.Command {
|
||||
&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)",
|
||||
cmd.Flags().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
||||
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
||||
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
||||
"If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
|
||||
)
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
|
||||
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -93,13 +122,24 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
||||
publicIP = &ip
|
||||
}
|
||||
|
||||
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
||||
Context: opts.context,
|
||||
addOpts := cli.AddMachineOptions{
|
||||
MachineName: opts.name,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
SkipInstall: opts.noInstall,
|
||||
Version: opts.version,
|
||||
})
|
||||
AutoConfirm: opts.yes,
|
||||
}
|
||||
if len(opts.wgEndpoints) > 0 {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
|
||||
}
|
||||
addOpts.WireguardEndpoints = endpoints
|
||||
}
|
||||
|
||||
clusterClient, machineClient, err := uncli.AddMachine(ctx, addOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -110,25 +150,37 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
||||
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)
|
||||
// Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service.
|
||||
err = spinner.New().
|
||||
Title(" Waiting for the machine to join the cluster...").
|
||||
Type(spinner.MiniDot).
|
||||
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
|
||||
TitleStyle(lipgloss.NewStyle()).
|
||||
ActionWithErr(func(ctx context.Context) error {
|
||||
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
|
||||
}).
|
||||
Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for machine to join the cluster: %w", err)
|
||||
}
|
||||
fmt.Println("Machine joined the cluster.")
|
||||
|
||||
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||
// that may cause a small downtime.
|
||||
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
||||
// use the deployed image version. Otherwise, use the latest version.
|
||||
// use the deployed image version.
|
||||
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
|
||||
// issues accessing the Machine API of existing machines in the cluster.
|
||||
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
|
||||
caddyImage := ""
|
||||
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||
if err != nil {
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
if errors.Is(err, api.ErrNotFound) {
|
||||
// Caddy service is not deployed.
|
||||
return nil
|
||||
}
|
||||
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
|
||||
@@ -142,47 +194,53 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
||||
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)
|
||||
}
|
||||
|
||||
plan, err := d.Plan(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if len(plan.Operations) == 0 {
|
||||
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
|
||||
} else {
|
||||
// Initialise a machine and container name resolver to properly format the plan output.
|
||||
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, caddySvc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("caddy deployment plan:")
|
||||
fmt.Println(plan.Format(resolver))
|
||||
fmt.Println()
|
||||
|
||||
if !opts.yes {
|
||||
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", d.Spec.Name))
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
+96
-15
@@ -4,7 +4,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh/spinner"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||
@@ -12,36 +16,65 @@ import (
|
||||
"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/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type initOptions struct {
|
||||
context string
|
||||
dnsEndpoint string
|
||||
name string
|
||||
network string
|
||||
noCaddy bool
|
||||
noDNS bool
|
||||
noInstall bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
version string
|
||||
context string
|
||||
wgEndpoints []string
|
||||
yes bool
|
||||
}
|
||||
|
||||
func NewInitCommand() *cobra.Command {
|
||||
opts := initOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "init [USER@HOST:PORT]",
|
||||
Use: "init [schema://]USER@HOST[:PORT]",
|
||||
Short: "Initialise a new cluster with a remote machine as the first member.",
|
||||
// TODO: include usage examples of initialising a remote machine.
|
||||
Long: `Initialise a new cluster by setting up a remote machine as the first member.
|
||||
This command creates a new context in your Uncloud config to manage the cluster.
|
||||
|
||||
Connection methods:
|
||||
ssh://user@host - Use built-in SSH library (default, no prefix required)
|
||||
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
|
||||
Example: ` # Initialise a new cluster with default settings.
|
||||
uc machine init root@<your-server-ip>
|
||||
|
||||
# Initialise with a context name 'prod' in the Uncloud config (~/.config/uncloud/config.yaml) and machine name 'vps1'.
|
||||
uc machine init root@<your-server-ip> -c prod -n vps1
|
||||
|
||||
# Initialise with a non-root user and custom SSH port and key.
|
||||
uc machine init ubuntu@<your-server-ip>:2222 -i ~/.ssh/mykey
|
||||
|
||||
# Initialise without Caddy (no reverse proxy) and without an automatically managed domain name (xxxxxx.uncld.dev).
|
||||
# You can deploy Caddy with 'uc caddy deploy' and reserve a domain with 'uc dns reserve' later.
|
||||
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
|
||||
// TODO: support initialising a cluster on the local machine.
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
var remoteMachine *cli.RemoteMachine
|
||||
if len(args) > 0 {
|
||||
user, host, port, err := config.SSHDestination(args[0]).Parse()
|
||||
// Determine if SSH CLI is requested and strip scheme
|
||||
destination := args[0]
|
||||
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
|
||||
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
||||
destination = strings.TrimPrefix(destination, "ssh://")
|
||||
|
||||
user, host, port, err := config.SSHDestination(destination).Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse remote machine: %w", err)
|
||||
}
|
||||
@@ -50,12 +83,18 @@ func NewInitCommand() *cobra.Command {
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
UseSSHCLI: useSSHCLI,
|
||||
}
|
||||
}
|
||||
|
||||
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", cli.DefaultContextName,
|
||||
"Name of the new context to be created in the Uncloud config to manage the cluster.",
|
||||
)
|
||||
cmd.Flags().StringVar(&opts.dnsEndpoint, "dns-endpoint", dns.DefaultUncloudDNSAPIEndpoint,
|
||||
"API endpoint for the Uncloud DNS service.")
|
||||
cmd.Flags().StringVarP(
|
||||
@@ -68,11 +107,16 @@ func NewInitCommand() *cobra.Command {
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noCaddy, "no-caddy", false,
|
||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||
"Don't deploy Caddy reverse proxy service to the machine. You can deploy it later with 'uc caddy deploy'.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noDNS, "no-dns", false,
|
||||
"Don't reserve a cluster domain in Uncloud DNS.",
|
||||
"Don't reserve a cluster domain in Uncloud DNS. You can reserve it later with 'uc dns reserve'.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noInstall, "no-install", false,
|
||||
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
||||
"Assumes they're already installed and running.",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "auto",
|
||||
@@ -88,15 +132,27 @@ func NewInitCommand() *cobra.Command {
|
||||
&opts.version, "version", "latest",
|
||||
"Version of the Uncloud daemon to install on the machine.",
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", cli.DefaultContextName,
|
||||
"Name of the created context for the initialised cluster in the Uncloud config.",
|
||||
cmd.Flags().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
||||
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
||||
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
||||
"If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
|
||||
)
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
|
||||
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
|
||||
if uncli.Config == nil {
|
||||
// Config is nil when connecting directly to a remote machine (--connect) without using Uncloud config.
|
||||
return fmt.Errorf("do not specify --connect when initialising a new cluster")
|
||||
}
|
||||
|
||||
netPrefix, err := netip.ParsePrefix(opts.network)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse network CIDR: %w", err)
|
||||
@@ -115,27 +171,52 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
||||
}
|
||||
publicIP = &ip
|
||||
}
|
||||
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{
|
||||
initOpts := cli.InitClusterOptions{
|
||||
Context: opts.context,
|
||||
MachineName: opts.name,
|
||||
Network: netPrefix,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
SkipInstall: opts.noInstall,
|
||||
Version: opts.version,
|
||||
})
|
||||
AutoConfirm: opts.yes,
|
||||
}
|
||||
if len(opts.wgEndpoints) > 0 {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
|
||||
}
|
||||
initOpts.WireguardEndpoints = endpoints
|
||||
}
|
||||
|
||||
client, err := uncli.InitCluster(ctx, initOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Since the cluster API needs a few moments to become ready after cluster initialisation,
|
||||
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
|
||||
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
|
||||
err = spinner.New().
|
||||
Title(" Waiting for the cluster to be ready...").
|
||||
Type(spinner.MiniDot).
|
||||
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
|
||||
TitleStyle(lipgloss.NewStyle()).
|
||||
ActionWithErr(func(ctx context.Context) error {
|
||||
return client.WaitClusterReady(ctx, 1*time.Minute)
|
||||
}).
|
||||
Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for cluster to be ready: %w", err)
|
||||
}
|
||||
fmt.Println("Cluster is ready.")
|
||||
|
||||
if opts.noCaddy && opts.noDNS {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deploy the Caddy service to the initialised machine.
|
||||
// The creation of a deployment plan talks to cluster API. Since the API needs a few moments to become available
|
||||
// after cluster initialisation, we keep the user informed during this wait.
|
||||
fmt.Println("Waiting for the machine to be ready...")
|
||||
fmt.Println()
|
||||
|
||||
if !opts.noDNS {
|
||||
|
||||
@@ -14,25 +14,20 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
return list(cmd.Context(), uncli)
|
||||
},
|
||||
}
|
||||
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)
|
||||
func list(ctx context.Context, uncli *cli.CLI) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
@@ -46,7 +41,7 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
// 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 {
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS\tMACHINE ID"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
// Print rows.
|
||||
@@ -68,8 +63,8 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
}
|
||||
|
||||
if _, err = fmt.Fprintf(
|
||||
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||
strings.Join(endpoints, ", "),
|
||||
tw, "%s\t%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||
strings.Join(endpoints, ", "), member.Machine.Id,
|
||||
); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
)
|
||||
|
||||
func NewRenameCommand() *cobra.Command {
|
||||
var contextName string
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename OLD_NAME NEW_NAME",
|
||||
Short: "Rename a machine in the cluster.",
|
||||
@@ -20,18 +19,14 @@ configuration including network settings, public IP, and cluster membership.`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return rename(cmd.Context(), uncli, contextName, args[0], args[1])
|
||||
return rename(cmd.Context(), uncli, args[0], args[1])
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&contextName, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func rename(ctx context.Context, uncli *cli.CLI, contextName, oldName, newName string) error {
|
||||
client, err := uncli.ConnectCluster(ctx, contextName)
|
||||
func rename(ctx context.Context, uncli *cli.CLI, oldName, newName string) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
type removeOptions struct {
|
||||
noReset bool
|
||||
yes bool
|
||||
context string
|
||||
}
|
||||
|
||||
func NewRmCommand() *cobra.Command {
|
||||
@@ -39,8 +38,6 @@ func NewRmCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)")
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Do not prompt for confirmation before removing the machine.")
|
||||
cmd.Flags().BoolVar(&opts.noReset, "no-reset", false,
|
||||
@@ -51,14 +48,14 @@ func NewRmCommand() *cobra.Command {
|
||||
|
||||
func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOptions) error {
|
||||
// TODO: automatically choose a connection to the machine that is not being removed.
|
||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Verify the machine exists and list all service containers on it including stopped ones.
|
||||
mctx, machines, err := api.ProxyMachinesContext(ctx, client, []string{nameOrID})
|
||||
mctx, machines, err := client.ProxyMachinesContext(ctx, []string{nameOrID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -155,8 +152,21 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: remove the connection to the machine from the uncloud config if it exists. We need a way to associate
|
||||
// the machine with its connection in the config, e.g. by storing the machine name in the connection metadata.
|
||||
// Remove the connection to the machine from the uncloud config if it exists.
|
||||
if uncli.Config != nil {
|
||||
contextName := uncli.GetContextOverrideOrCurrent()
|
||||
if context, ok := uncli.Config.Contexts[contextName]; ok {
|
||||
for i, c := range context.Connections {
|
||||
if c.MachineID == m.Id {
|
||||
context.Connections = slices.Delete(context.Connections, i, i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := uncli.Config.Save(); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: If Caddy was running on this machine and a cluster domain is reserved,
|
||||
// let the user know that the DNS records should be updated.
|
||||
|
||||
@@ -8,7 +8,7 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "machine",
|
||||
Aliases: []string{"m"},
|
||||
Short: "Manage machines in an Uncloud cluster.",
|
||||
Short: "Manage machines in the cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewAddCommand(),
|
||||
@@ -17,7 +17,6 @@ func NewRootCommand() *cobra.Command {
|
||||
NewRenameCommand(),
|
||||
NewRmCommand(),
|
||||
NewUpdateCommand(),
|
||||
NewTokenCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/daemon"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type tokenOptions struct {
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewTokenCommand() *cobra.Command {
|
||||
opts := tokenOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "token",
|
||||
Short: "Print the local machine's token for adding it to a cluster.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
token, err := daemon.MachineToken(opts.dataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get machine token: %w", err)
|
||||
}
|
||||
tokenStr, err := token.String()
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode machine token: %w", err)
|
||||
}
|
||||
fmt.Println(tokenStr)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.dataDir, "data-dir", "d", machine.DefaultDataDir,
|
||||
"Directory for storing persistent machine state.")
|
||||
_ = cmd.MarkFlagDirname("data-dir")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -4,30 +4,43 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type updateOptions struct {
|
||||
name string
|
||||
publicIP string
|
||||
context string
|
||||
wgEndpoints []string
|
||||
}
|
||||
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
opts := updateOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Use: "update MACHINE [flags]",
|
||||
Short: "Update machine configuration in the cluster.",
|
||||
Long: `Update machine configuration in the cluster.
|
||||
|
||||
This command allows setting various machine properties including:
|
||||
- Machine name (--name)
|
||||
- Public IP address (--public-ip)
|
||||
Change the name, public IP address, or WireGuard endpoints of an existing machine.
|
||||
At least one flag must be specified to perform an update.`,
|
||||
Example: ` # Rename a machine.
|
||||
uc machine update machine1 --name web-server
|
||||
|
||||
At least one flag must be specified to perform an update operation.`,
|
||||
# Set the public IP address of a machine.
|
||||
uc machine update machine1 --public-ip 203.0.113.10
|
||||
|
||||
# Remove the public IP address from a machine.
|
||||
uc machine update machine1 --public-ip none
|
||||
|
||||
# Update WireGuard endpoints for a machine.
|
||||
uc machine update machine1 --wg-endpoint 203.0.113.10 --wg-endpoint 192.168.1.5
|
||||
|
||||
# Update multiple properties at once.
|
||||
uc machine update machine1 --name web-server --public-ip 203.0.113.10`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
@@ -41,23 +54,27 @@ At least one flag must be specified to perform an update operation.`,
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "",
|
||||
fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.", PublicIPNone),
|
||||
fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.",
|
||||
PublicIPNone),
|
||||
)
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.context, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
cmd.Flags().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
||||
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
||||
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts updateOptions, machineNameOrID string) error {
|
||||
// Check if at least one flag was explicitly set
|
||||
if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") {
|
||||
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip)")
|
||||
// Check if at least one flag was explicitly set.
|
||||
if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") && !cmd.Flags().Changed("wg-endpoint") {
|
||||
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip, --wg-endpoint)")
|
||||
}
|
||||
|
||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,6 +109,19 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and set endpoints if the flag was explicitly provided.
|
||||
if cmd.Flags().Changed("wg-endpoint") {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(endpoints) == 0 {
|
||||
return fmt.Errorf("at least one endpoint must be specified if --wg-endpoint flag is used")
|
||||
}
|
||||
req.Endpoints = endpoints
|
||||
}
|
||||
|
||||
// Perform the update operation
|
||||
updatedMachine, err := client.UpdateMachine(ctx, req)
|
||||
if err != nil {
|
||||
@@ -118,8 +148,24 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
|
||||
}
|
||||
changes = append(changes, fmt.Sprintf("public IP: %s -> %s", oldIP, newIP))
|
||||
}
|
||||
if cmd.Flags().Changed("wg-endpoint") {
|
||||
formatEndpoints := func(eps []*pb.IPPort) string {
|
||||
if len(eps) == 0 {
|
||||
return "none"
|
||||
}
|
||||
parts := make([]string, len(eps))
|
||||
for i, ep := range eps {
|
||||
ap, _ := ep.ToAddrPort()
|
||||
parts[i] = ap.String()
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
oldEndpoints := formatEndpoints(machine.Machine.Network.Endpoints)
|
||||
newEndpoints := formatEndpoints(updatedMachine.Network.Endpoints)
|
||||
changes = append(changes, fmt.Sprintf("endpoints: %s -> %s", oldEndpoints, newEndpoints))
|
||||
}
|
||||
|
||||
fmt.Printf("Machine %q (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id)
|
||||
fmt.Printf("Machine '%s' (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id)
|
||||
for _, change := range changes {
|
||||
fmt.Printf(" %s\n", change)
|
||||
}
|
||||
|
||||
+65
-17
@@ -6,15 +6,19 @@ import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"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/image"
|
||||
"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/cmd/uncloud/wg"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/log"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -22,17 +26,24 @@ import (
|
||||
type globalOptions struct {
|
||||
configPath string
|
||||
connect string
|
||||
context string
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.InitLoggerFromEnv()
|
||||
|
||||
opts := globalOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "uc",
|
||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||
Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.",
|
||||
Version: version.String(),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
|
||||
cli.BindEnvToFlag(cmd, "context", "UNCLOUD_CONTEXT")
|
||||
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
|
||||
|
||||
var conn *config.MachineConnection
|
||||
if opts.connect != "" {
|
||||
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||
@@ -43,11 +54,17 @@ func main() {
|
||||
conn = &config.MachineConnection{
|
||||
TCP: &addrPort,
|
||||
}
|
||||
} else {
|
||||
dest := opts.connect
|
||||
if strings.HasPrefix(dest, "ssh://") {
|
||||
dest = dest[len("ssh://"):]
|
||||
} else if strings.HasPrefix(opts.connect, "ssh+cli://") {
|
||||
dest := opts.connect[len("ssh+cli://"):]
|
||||
conn = &config.MachineConnection{
|
||||
SSHCLI: config.SSHDestination(dest),
|
||||
}
|
||||
} else if strings.HasPrefix(opts.connect, "unix://") {
|
||||
conn = &config.MachineConnection{
|
||||
Unix: opts.connect[len("unix://"):],
|
||||
}
|
||||
} else {
|
||||
dest := strings.TrimPrefix(opts.connect, "ssh://")
|
||||
conn = &config.MachineConnection{
|
||||
SSH: config.SSHDestination(dest),
|
||||
}
|
||||
@@ -55,7 +72,7 @@ func main() {
|
||||
}
|
||||
|
||||
configPath := fs.ExpandHomeDir(opts.configPath)
|
||||
uncli, err := cli.New(configPath, conn)
|
||||
uncli, err := cli.New(configPath, conn, opts.context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialise CLI: %w", err)
|
||||
}
|
||||
@@ -65,29 +82,60 @@ func main() {
|
||||
}
|
||||
|
||||
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.
|
||||
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
||||
"Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock")
|
||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||
"Path to the Uncloud configuration file.")
|
||||
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||
// TODO: make --context a global flag and pass it as a value of the command context.
|
||||
cmd.PersistentFlags().StringVarP(&opts.context, "context", "c", "",
|
||||
"Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]")
|
||||
|
||||
// Set custom help function to show links to docs and Discord only for the root 'uc' command.
|
||||
defaultHelpFunc := cmd.HelpFunc()
|
||||
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
|
||||
defaultHelpFunc(c, args)
|
||||
// Only show links for the root 'uc' command.
|
||||
if c.Name() == "uc" {
|
||||
urlStyle := lipgloss.NewStyle().
|
||||
Underline(true).
|
||||
Foreground(lipgloss.Color("12")) // light blue
|
||||
|
||||
fmt.Fprintln(c.OutOrStdout())
|
||||
fmt.Fprintf(c.OutOrStdout(), "Learn more about Uncloud: %s\n",
|
||||
urlStyle.Render("https://uncloud.run/docs"))
|
||||
fmt.Fprintf(c.OutOrStdout(), "Join our Discord community: %s\n",
|
||||
urlStyle.Render("https://uncloud.run/discord"))
|
||||
}
|
||||
})
|
||||
|
||||
cmd.AddGroup(&cobra.Group{
|
||||
ID: "service",
|
||||
Title: "Deploy and manage services:",
|
||||
})
|
||||
|
||||
cmd.AddCommand(
|
||||
NewBuildCommand(),
|
||||
NewDeployCommand(),
|
||||
NewDocsCommand(),
|
||||
NewBuildCommand(),
|
||||
NewImagesCommand(),
|
||||
NewPsCommand(),
|
||||
caddy.NewRootCommand(),
|
||||
cmdcontext.NewRootCommand(),
|
||||
dns.NewRootCommand(),
|
||||
image.NewRootCommand(),
|
||||
machine.NewRootCommand(),
|
||||
service.NewRootCommand(),
|
||||
service.NewInspectCommand(),
|
||||
service.NewListCommand(),
|
||||
service.NewRmCommand(),
|
||||
service.NewRunCommand(),
|
||||
service.NewScaleCommand(),
|
||||
service.NewExecCommand("service"),
|
||||
service.NewInspectCommand("service"),
|
||||
service.NewListCommand("service"),
|
||||
service.NewLogsCommand("service"),
|
||||
service.NewRmCommand("service"),
|
||||
service.NewRunCommand("service"),
|
||||
service.NewScaleCommand("service"),
|
||||
service.NewStartCommand("service"),
|
||||
service.NewStopCommand("service"),
|
||||
volume.NewRootCommand(),
|
||||
wg.NewRootCommand(),
|
||||
)
|
||||
cobra.CheckErr(cmd.Execute())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh/spinner"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
)
|
||||
|
||||
const (
|
||||
sortByService = "service"
|
||||
sortByMachine = "machine"
|
||||
sortByHealth = "health"
|
||||
)
|
||||
|
||||
type containerHighlight int
|
||||
|
||||
const (
|
||||
highlightDanger containerHighlight = iota
|
||||
highlightWarning
|
||||
highlightSuccess
|
||||
highlightNormal
|
||||
)
|
||||
|
||||
type psOptions struct {
|
||||
sortBy string
|
||||
}
|
||||
|
||||
func NewPsCommand() *cobra.Command {
|
||||
opts := psOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "ps",
|
||||
Short: "List all service containers.",
|
||||
Long: `List all service containers across all machines in the cluster.
|
||||
|
||||
This command provides a comprehensive overview of all running containers that are part of a service,
|
||||
making it easy to see the distribution and status of containers across the cluster.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
if opts.sortBy != sortByService && opts.sortBy != sortByMachine && opts.sortBy != sortByHealth {
|
||||
return fmt.Errorf("invalid value for --sort: %q, must be one of '%s', '%s' or '%s'", opts.sortBy,
|
||||
sortByService, sortByMachine, sortByHealth)
|
||||
}
|
||||
|
||||
return runPs(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: "service",
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.sortBy, "sort", "s", sortByService,
|
||||
"Sort containers by 'service', 'machine', or 'health'.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type containerInfo struct {
|
||||
serviceName string
|
||||
machineName string
|
||||
id string
|
||||
name string
|
||||
image string
|
||||
status string
|
||||
highlight containerHighlight
|
||||
created time.Time
|
||||
ip string
|
||||
}
|
||||
|
||||
func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
var containers []containerInfo
|
||||
err = spinner.New().
|
||||
Title(" Collecting container info...").
|
||||
Type(spinner.MiniDot).
|
||||
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
|
||||
ActionWithErr(func(ctx context.Context) error {
|
||||
containers, err = collectContainers(ctx, clusterClient)
|
||||
return err
|
||||
}).
|
||||
Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("collect containers: %w", err)
|
||||
}
|
||||
|
||||
// Sort the containers based on the sorting option.
|
||||
sort.SliceStable(containers, func(i, j int) bool {
|
||||
a, b := containers[i], containers[j]
|
||||
switch opts.sortBy {
|
||||
case sortByHealth:
|
||||
if a.highlight != b.highlight {
|
||||
return a.highlight < b.highlight
|
||||
}
|
||||
if a.serviceName != b.serviceName {
|
||||
return a.serviceName < b.serviceName
|
||||
}
|
||||
case sortByMachine:
|
||||
if a.machineName != b.machineName {
|
||||
return a.machineName < b.machineName
|
||||
}
|
||||
if a.serviceName != b.serviceName {
|
||||
return a.serviceName < b.serviceName
|
||||
}
|
||||
default: // sortByService
|
||||
if a.serviceName != b.serviceName {
|
||||
return a.serviceName < b.serviceName
|
||||
}
|
||||
}
|
||||
// Fallback to creation time (newest first).
|
||||
return a.created.After(b.created)
|
||||
})
|
||||
|
||||
return printContainers(containers)
|
||||
}
|
||||
|
||||
func printContainers(containers []containerInfo) error {
|
||||
t := table.New().
|
||||
// Remove the default border.
|
||||
Border(lipgloss.Border{}).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderLeft(false).
|
||||
BorderRight(false).
|
||||
BorderHeader(false).
|
||||
BorderColumn(false).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
if row == table.HeaderRow {
|
||||
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
|
||||
}
|
||||
// Regular style for data rows with padding.
|
||||
return lipgloss.NewStyle().PaddingRight(3)
|
||||
})
|
||||
|
||||
t.Headers("SERVICE", "CONTAINER ID", "CONTAINER NAME", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
|
||||
|
||||
for _, ctr := range containers {
|
||||
id := ctr.id
|
||||
if len(id) > 12 {
|
||||
id = id[:12]
|
||||
}
|
||||
|
||||
created := units.HumanDuration(time.Now().UTC().Sub(ctr.created)) + " ago"
|
||||
|
||||
var statusStyle lipgloss.Style
|
||||
switch ctr.highlight {
|
||||
case highlightSuccess:
|
||||
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green
|
||||
case highlightDanger:
|
||||
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Red
|
||||
case highlightWarning:
|
||||
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // Yellow
|
||||
default:
|
||||
statusStyle = lipgloss.NewStyle() // Default
|
||||
}
|
||||
|
||||
t.Row(
|
||||
ctr.serviceName,
|
||||
id,
|
||||
ctr.name,
|
||||
ctr.image,
|
||||
created,
|
||||
statusStyle.Render(ctr.status),
|
||||
ctr.ip,
|
||||
ctr.machineName,
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo, error) {
|
||||
listCtx, machines, err := cli.ProxyMachinesContext(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proxy machines context: %w", err)
|
||||
}
|
||||
|
||||
// Create a map of IP to machine name for resolving response metadata
|
||||
machinesNamesByIP := make(map[string]string)
|
||||
for _, m := range machines {
|
||||
if addr, err := m.Machine.Network.ManagementIp.ToAddr(); err == nil {
|
||||
machinesNamesByIP[addr.String()] = m.Machine.Name
|
||||
}
|
||||
}
|
||||
|
||||
// List all service containers across all machines in the cluster.
|
||||
machineContainers, err := cli.Docker.ListServiceContainers(
|
||||
listCtx, "", container.ListOptions{All: true},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list service containers: %w", err)
|
||||
}
|
||||
|
||||
var containers []containerInfo
|
||||
for _, msc := range machineContainers {
|
||||
// Metadata can be nil if the request was broadcasted to only one machine.
|
||||
if msc.Metadata == nil && len(machineContainers) > 1 {
|
||||
return nil, fmt.Errorf("something went wrong with gRPC proxy: metadata is missing for a machine response")
|
||||
}
|
||||
|
||||
machineName := "unknown"
|
||||
if msc.Metadata != nil {
|
||||
var ok bool
|
||||
machineName, ok = machinesNamesByIP[msc.Metadata.Machine]
|
||||
if !ok {
|
||||
// Fallback to machine's IP as name.
|
||||
machineName = msc.Metadata.Machine
|
||||
}
|
||||
} else {
|
||||
// Fallback to the first available machine name.
|
||||
if len(machines) > 0 {
|
||||
machineName = machines[0].Machine.Name
|
||||
}
|
||||
}
|
||||
|
||||
if msc.Metadata != nil && msc.Metadata.Error != "" {
|
||||
client.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
|
||||
msc.Metadata.Error))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ctr := range msc.Containers {
|
||||
if ctr.Container.State == nil || ctr.Container.Config == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
status, err := ctr.Container.HumanState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get human state for container %s: %w", ctr.Container.ID, err)
|
||||
}
|
||||
|
||||
var highlight containerHighlight
|
||||
healthStatus := ""
|
||||
if ctr.Container.State.Health != nil {
|
||||
healthStatus = ctr.Container.State.Health.Status
|
||||
}
|
||||
|
||||
if healthStatus == container.Unhealthy || ctr.Container.State.Status == "dead" || ctr.Container.State.OOMKilled || ctr.Container.State.Dead {
|
||||
highlight = highlightDanger
|
||||
} else if healthStatus == container.Healthy {
|
||||
highlight = highlightSuccess
|
||||
} else if ctr.Container.State.Status == "running" {
|
||||
highlight = highlightNormal
|
||||
} else { // Other non-critical but noteworthy states
|
||||
highlight = highlightWarning
|
||||
}
|
||||
|
||||
created, _ := time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
||||
|
||||
ip := ctr.Container.UncloudNetworkIP()
|
||||
ipStr := ""
|
||||
// The container might not have an IP if it's not running or uses the host network.
|
||||
if ip.IsValid() {
|
||||
ipStr = ip.String()
|
||||
}
|
||||
|
||||
info := containerInfo{
|
||||
serviceName: ctr.ServiceName(),
|
||||
machineName: machineName,
|
||||
id: ctr.Container.ID,
|
||||
name: ctr.Container.Name,
|
||||
image: ctr.Container.Config.Image,
|
||||
status: status,
|
||||
highlight: highlight,
|
||||
created: created,
|
||||
ip: ipStr,
|
||||
}
|
||||
containers = append(containers, info)
|
||||
}
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
// mockDockerClient implements pb.DockerClient
|
||||
type mockDockerClient struct {
|
||||
pb.DockerClient // Embed to avoid implementing all methods
|
||||
listResp *pb.ListServiceContainersResponse
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ListServiceContainers(ctx context.Context, in *pb.ListServiceContainersRequest, opts ...grpc.CallOption) (*pb.ListServiceContainersResponse, error) {
|
||||
return m.listResp, m.listErr
|
||||
}
|
||||
|
||||
// mockClusterClient implements pb.ClusterClient
|
||||
type mockClusterClient struct {
|
||||
pb.ClusterClient // Embed to avoid implementing all methods
|
||||
machinesResp *pb.ListMachinesResponse
|
||||
machinesErr error
|
||||
}
|
||||
|
||||
func (m *mockClusterClient) ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*pb.ListMachinesResponse, error) {
|
||||
return m.machinesResp, m.machinesErr
|
||||
}
|
||||
|
||||
func TestCollectContainers_NilMetadata(t *testing.T) {
|
||||
// Setup container data
|
||||
containerData := map[string]interface{}{
|
||||
"Id": "container1",
|
||||
"Name": "test-container",
|
||||
"Config": map[string]interface{}{
|
||||
"Image": "test-image",
|
||||
},
|
||||
"State": map[string]interface{}{
|
||||
"Status": "running",
|
||||
"StartedAt": "2023-01-01T12:00:00Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||
},
|
||||
"NetworkSettings": map[string]interface{}{
|
||||
"Networks": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
containerJSON, err := json.Marshal(containerData)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceSpecJSON, err := json.Marshal(map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup mocks
|
||||
mockDocker := &mockDockerClient{
|
||||
listResp: &pb.ListServiceContainersResponse{
|
||||
Messages: []*pb.MachineServiceContainers{
|
||||
{
|
||||
Metadata: nil, // Simulating the issue: nil metadata
|
||||
Containers: []*pb.ServiceContainer{
|
||||
{
|
||||
Container: containerJSON,
|
||||
ServiceSpec: serviceSpecJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
machineIP := "10.0.0.1"
|
||||
mockCluster := &mockClusterClient{
|
||||
machinesResp: &pb.ListMachinesResponse{
|
||||
Machines: []*pb.MachineMember{
|
||||
{
|
||||
Machine: &pb.MachineInfo{
|
||||
Name: "machine-1",
|
||||
Network: &pb.NetworkConfig{
|
||||
ManagementIp: pb.NewIP(netip.MustParseAddr(machineIP)),
|
||||
},
|
||||
},
|
||||
State: pb.MachineMember_UP,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Construct client with mocks
|
||||
cli := &client.Client{
|
||||
Docker: &docker.Client{
|
||||
GRPCClient: mockDocker,
|
||||
},
|
||||
ClusterClient: mockCluster,
|
||||
}
|
||||
|
||||
// Execute
|
||||
containers, err := collectContainers(context.Background(), cli)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify
|
||||
assert.Len(t, containers, 1)
|
||||
if len(containers) > 0 {
|
||||
c := containers[0]
|
||||
assert.Equal(t, "container1", c.id)
|
||||
assert.Equal(t, "test-container", c.name)
|
||||
assert.Equal(t, "machine-1", c.machineName, "Should fall back to the single machine name when metadata is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectContainers_NilMetadata_MultipleMachines_Error(t *testing.T) {
|
||||
// If we have multiple machines but receive nil metadata, it should return an error as it is ambiguous
|
||||
|
||||
// Setup container data
|
||||
containerData1 := map[string]interface{}{
|
||||
"Id": "container1",
|
||||
}
|
||||
containerJSON1, _ := json.Marshal(containerData1)
|
||||
|
||||
containerData2 := map[string]interface{}{
|
||||
"Id": "container2",
|
||||
"Config": map[string]interface{}{
|
||||
"Image": "test-image",
|
||||
},
|
||||
"State": map[string]interface{}{
|
||||
"Status": "running",
|
||||
"StartedAt": "2023-01-01T12:00:00Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||
},
|
||||
"NetworkSettings": map[string]interface{}{
|
||||
"Networks": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
containerJSON2, _ := json.Marshal(containerData2)
|
||||
|
||||
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
|
||||
|
||||
// Setup mocks
|
||||
mockDocker := &mockDockerClient{
|
||||
listResp: &pb.ListServiceContainersResponse{
|
||||
Messages: []*pb.MachineServiceContainers{
|
||||
{
|
||||
Metadata: nil, // Nil metadata
|
||||
Containers: []*pb.ServiceContainer{
|
||||
{
|
||||
Container: containerJSON1,
|
||||
ServiceSpec: serviceSpecJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
|
||||
Containers: []*pb.ServiceContainer{
|
||||
{
|
||||
Container: containerJSON2,
|
||||
ServiceSpec: serviceSpecJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockCluster := &mockClusterClient{
|
||||
machinesResp: &pb.ListMachinesResponse{
|
||||
Machines: []*pb.MachineMember{
|
||||
{
|
||||
Machine: &pb.MachineInfo{
|
||||
Name: "machine-1",
|
||||
Network: &pb.NetworkConfig{
|
||||
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Machine: &pb.MachineInfo{
|
||||
Name: "machine-2",
|
||||
Network: &pb.NetworkConfig{
|
||||
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Construct client with mocks
|
||||
cli := &client.Client{
|
||||
Docker: &docker.Client{
|
||||
GRPCClient: mockDocker,
|
||||
},
|
||||
ClusterClient: mockCluster,
|
||||
}
|
||||
|
||||
// Execute
|
||||
_, err := collectContainers(context.Background(), cli)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "metadata is missing for a machine response")
|
||||
}
|
||||
|
||||
func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
|
||||
// Verify correct mapping of containers to machines when metadata is present
|
||||
|
||||
// Setup container data
|
||||
containerData1 := map[string]interface{}{
|
||||
"Id": "container1",
|
||||
"Name": "container-1",
|
||||
"Config": map[string]interface{}{
|
||||
"Image": "image-1",
|
||||
},
|
||||
"State": map[string]interface{}{
|
||||
"Status": "running",
|
||||
"StartedAt": "2023-01-01T12:00:00Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||
},
|
||||
"NetworkSettings": map[string]interface{}{
|
||||
"Networks": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
containerJSON1, _ := json.Marshal(containerData1)
|
||||
|
||||
containerData2 := map[string]interface{}{
|
||||
"Id": "container2",
|
||||
"Name": "container-2",
|
||||
"Config": map[string]interface{}{
|
||||
"Image": "image-2",
|
||||
},
|
||||
"State": map[string]interface{}{
|
||||
"Status": "running",
|
||||
"StartedAt": "2023-01-01T12:00:00Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||
},
|
||||
"NetworkSettings": map[string]interface{}{
|
||||
"Networks": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
containerJSON2, _ := json.Marshal(containerData2)
|
||||
|
||||
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
|
||||
|
||||
// Setup mocks
|
||||
mockDocker := &mockDockerClient{
|
||||
listResp: &pb.ListServiceContainersResponse{
|
||||
Messages: []*pb.MachineServiceContainers{
|
||||
{
|
||||
Metadata: &pb.Metadata{Machine: "10.0.0.1"},
|
||||
Containers: []*pb.ServiceContainer{
|
||||
{
|
||||
Container: containerJSON1,
|
||||
ServiceSpec: serviceSpecJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
|
||||
Containers: []*pb.ServiceContainer{
|
||||
{
|
||||
Container: containerJSON2,
|
||||
ServiceSpec: serviceSpecJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockCluster := &mockClusterClient{
|
||||
machinesResp: &pb.ListMachinesResponse{
|
||||
Machines: []*pb.MachineMember{
|
||||
{
|
||||
Machine: &pb.MachineInfo{
|
||||
Name: "machine-1",
|
||||
Network: &pb.NetworkConfig{
|
||||
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Machine: &pb.MachineInfo{
|
||||
Name: "machine-2",
|
||||
Network: &pb.NetworkConfig{
|
||||
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cli := &client.Client{
|
||||
Docker: &docker.Client{GRPCClient: mockDocker},
|
||||
ClusterClient: mockCluster,
|
||||
}
|
||||
|
||||
containers, err := collectContainers(context.Background(), cli)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, containers, 2)
|
||||
// Order is not guaranteed by the map iteration in logic or parallel fetch (though here it's mocked sequential),
|
||||
// but collectContainers just appends.
|
||||
// We'll find them by ID.
|
||||
for _, c := range containers {
|
||||
if c.id == "container1" {
|
||||
assert.Equal(t, "machine-1", c.machineName)
|
||||
} else if c.id == "container2" {
|
||||
assert.Equal(t, "machine-2", c.machineName)
|
||||
} else {
|
||||
t.Errorf("unexpected container id: %s", c.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
|
||||
// Case: 1 msc with nil metadata but no machines at all
|
||||
|
||||
containerData := map[string]interface{}{
|
||||
"Id": "container1",
|
||||
"Config": map[string]interface{}{
|
||||
"Image": "test-image",
|
||||
},
|
||||
"State": map[string]interface{}{
|
||||
"Status": "running",
|
||||
"StartedAt": "2023-01-01T12:00:00Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||
},
|
||||
"NetworkSettings": map[string]interface{}{
|
||||
"Networks": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
containerJSON, _ := json.Marshal(containerData)
|
||||
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
|
||||
|
||||
mockDocker := &mockDockerClient{
|
||||
listResp: &pb.ListServiceContainersResponse{
|
||||
Messages: []*pb.MachineServiceContainers{
|
||||
{
|
||||
Metadata: nil,
|
||||
Containers: []*pb.ServiceContainer{
|
||||
{
|
||||
Container: containerJSON,
|
||||
ServiceSpec: serviceSpecJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// No machines in cluster response
|
||||
mockCluster := &mockClusterClient{
|
||||
machinesResp: &pb.ListMachinesResponse{
|
||||
Machines: []*pb.MachineMember{},
|
||||
},
|
||||
}
|
||||
|
||||
cli := &client.Client{
|
||||
Docker: &docker.Client{GRPCClient: mockDocker},
|
||||
ClusterClient: mockCluster,
|
||||
}
|
||||
|
||||
containers, err := collectContainers(context.Background(), cli)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, containers, 1)
|
||||
if len(containers) > 0 {
|
||||
assert.Equal(t, "unknown", containers[0].machineName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) {
|
||||
// Case: msc with metadata that is not in the IP-to-name mapping
|
||||
|
||||
containerData := map[string]interface{}{
|
||||
"Id": "container1",
|
||||
"Config": map[string]interface{}{
|
||||
"Image": "test-image",
|
||||
},
|
||||
"State": map[string]interface{}{
|
||||
"Status": "running",
|
||||
"StartedAt": "2023-01-01T12:00:00Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||
},
|
||||
"NetworkSettings": map[string]interface{}{
|
||||
"Networks": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
containerJSON, _ := json.Marshal(containerData)
|
||||
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
|
||||
|
||||
mockDocker := &mockDockerClient{
|
||||
listResp: &pb.ListServiceContainersResponse{
|
||||
Messages: []*pb.MachineServiceContainers{
|
||||
{
|
||||
Metadata: &pb.Metadata{Machine: "10.0.0.99"}, // Unknown IP
|
||||
Containers: []*pb.ServiceContainer{
|
||||
{
|
||||
Container: containerJSON,
|
||||
ServiceSpec: serviceSpecJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockCluster := &mockClusterClient{
|
||||
machinesResp: &pb.ListMachinesResponse{
|
||||
Machines: []*pb.MachineMember{
|
||||
{
|
||||
Machine: &pb.MachineInfo{
|
||||
Name: "machine-1",
|
||||
Network: &pb.NetworkConfig{
|
||||
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cli := &client.Client{
|
||||
Docker: &docker.Client{GRPCClient: mockDocker},
|
||||
ClusterClient: mockCluster,
|
||||
}
|
||||
|
||||
containers, err := collectContainers(context.Background(), cli)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, containers, 1)
|
||||
if len(containers) > 0 {
|
||||
// Should fallback to the IP/string in metadata
|
||||
assert.Equal(t, "10.0.0.99", containers[0].machineName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type execCliOptions struct {
|
||||
detach bool
|
||||
interactive bool
|
||||
noTty bool
|
||||
containerId string
|
||||
}
|
||||
|
||||
var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
|
||||
|
||||
func NewExecCommand(groupID string) *cobra.Command {
|
||||
opts := execCliOptions{}
|
||||
|
||||
execCmd := &cobra.Command{
|
||||
Use: "exec [OPTIONS] SERVICE [COMMAND ARGS...]",
|
||||
Short: "Execute a command in a running service container.",
|
||||
Long: `Execute a command (interactive shell by default) in a running container within a service.
|
||||
If the service has multiple replicas and no container ID is specified, the command will be executed in a random container.
|
||||
`,
|
||||
Example: `
|
||||
# Start an interactive shell ("bash" or "sh" will be tried by default)
|
||||
uc exec web-service
|
||||
|
||||
# Start an interactive shell with explicit command
|
||||
uc exec web-service /bin/zsh
|
||||
|
||||
# List files in the specific container of the service; --container accepts full ID or a (unique) prefix
|
||||
uc exec --container d792e web-service ls -la
|
||||
|
||||
# Pipe input to a command inside the service container
|
||||
cat backup.sql | uc exec -T db-service psql -U postgres mydb
|
||||
|
||||
# Run a task in the background (detached mode)
|
||||
uc exec -d web-service /scripts/cleanup.sh`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
serviceName := args[0]
|
||||
command := args[1:]
|
||||
if len(command) == 0 {
|
||||
command = DEFAULT_COMMAND
|
||||
}
|
||||
return runExec(cmd.Context(), uncli, serviceName, command, opts)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
|
||||
execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background")
|
||||
|
||||
execCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", false,
|
||||
"Disable pseudo-TTY allocation. By default 'uc exec' allocates a TTY when connected to a terminal.")
|
||||
|
||||
// Keep "-i" and "-t" flags hidden for compatibility with docker exec
|
||||
execCmd.Flags().BoolVarP(&opts.interactive, "interactive", "i", true, "Keep STDIN open even if not attached")
|
||||
execCmd.Flags().MarkHidden("interactive")
|
||||
|
||||
execCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY")
|
||||
execCmd.Flags().MarkHidden("tty")
|
||||
|
||||
// Common flags
|
||||
execCmd.Flags().StringVar(&opts.containerId, "container", "",
|
||||
"ID of the container to exec into. Accepts full ID or a unique prefix "+
|
||||
"(default is the random container of the service)")
|
||||
|
||||
// This tells Cobra that all flags must come before positional arguments, so that
|
||||
// commands with their own flags can be handled correctly.
|
||||
execCmd.Flags().SetInterspersed(false)
|
||||
|
||||
return execCmd
|
||||
}
|
||||
|
||||
func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error {
|
||||
// Disable TTY allocation if not connected to a terminal
|
||||
if !cli.IsStdoutTerminal() {
|
||||
opts.noTty = true
|
||||
}
|
||||
|
||||
if !opts.detach {
|
||||
// Check if we're trying to attach to a TTY from a non-TTY client, e.g.
|
||||
// when doing an 'cmd | uc exec ...'
|
||||
stdin := streams.NewIn(os.Stdin)
|
||||
// TODO: this logic/behavior mirrors docker-compose, but we can be smarter about it and detect TTY dynamically
|
||||
if err := stdin.CheckTty(opts.interactive, !opts.noTty); err != nil {
|
||||
return fmt.Errorf("check TTY: %w; use -T option to disable TTY allocation", err)
|
||||
}
|
||||
}
|
||||
|
||||
client, err := uncli.ConnectClusterWithOptions(ctx, cli.ConnectOptions{
|
||||
ShowProgress: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
execConfig := api.ExecOptions{
|
||||
Command: command,
|
||||
AttachStdin: opts.interactive,
|
||||
Tty: !opts.noTty,
|
||||
Detach: opts.detach,
|
||||
}
|
||||
|
||||
if !opts.detach {
|
||||
execConfig.AttachStdout = true
|
||||
execConfig.AttachStderr = true
|
||||
}
|
||||
|
||||
exitCode, err := client.ExecContainer(ctx, serviceName, opts.containerId, execConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec container: %w", err)
|
||||
}
|
||||
|
||||
// For non-detached mode, exit with the same code as the executed command
|
||||
if !opts.detach {
|
||||
if exitCode != 0 {
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -4,21 +4,22 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type inspectOptions struct {
|
||||
service string
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewInspectCommand() *cobra.Command {
|
||||
func NewInspectCommand(groupID string) *cobra.Command {
|
||||
opts := inspectOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "inspect SERVICE",
|
||||
@@ -29,16 +30,13 @@ func NewInspectCommand() *cobra.Command {
|
||||
opts.service = args[0]
|
||||
return inspect(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
@@ -58,23 +56,31 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||
machinesNamesByID[m.Machine.Id] = m.Machine.Name
|
||||
}
|
||||
|
||||
fmt.Printf("ID: %s\n", svc.ID)
|
||||
fmt.Printf("Service ID: %s\n", svc.ID)
|
||||
fmt.Printf("Name: %s\n", svc.Name)
|
||||
fmt.Printf("Mode: %s\n", svc.Mode)
|
||||
fmt.Println()
|
||||
|
||||
// Parse created times for sorting and display.
|
||||
createdTimes := make(map[string]time.Time, len(svc.Containers))
|
||||
for _, ctr := range svc.Containers {
|
||||
createdTimes[ctr.Container.ID], _ = time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
||||
}
|
||||
|
||||
// Sort containers by created time (newest first).
|
||||
slices.SortFunc(svc.Containers, func(a, b api.MachineServiceContainer) int {
|
||||
return createdTimes[b.Container.ID].Compare(createdTimes[a.Container.ID])
|
||||
})
|
||||
|
||||
// Print the list of containers in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tMACHINE"); err != nil {
|
||||
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tIP ADDRESS\tMACHINE"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
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"
|
||||
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
|
||||
|
||||
machine := machinesNamesByID[ctr.MachineID]
|
||||
if machine == "" {
|
||||
@@ -85,13 +91,21 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||
return fmt.Errorf("get human state: %w", err)
|
||||
}
|
||||
|
||||
ip := ctr.Container.UncloudNetworkIP()
|
||||
ipStr := ""
|
||||
// The container might not have an IP if it's not running or uses the host network.
|
||||
if ip.IsValid() {
|
||||
ipStr = ip.String()
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(
|
||||
tw,
|
||||
"%s\t%s\t%s\t%s\t%s\n",
|
||||
"%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
stringid.TruncateID(ctr.Container.ID),
|
||||
ctr.Container.Config.Image,
|
||||
created,
|
||||
state,
|
||||
ipStr,
|
||||
machine,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"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/spf13/cobra"
|
||||
)
|
||||
|
||||
type logsOptions struct {
|
||||
files []string
|
||||
follow bool
|
||||
tail string
|
||||
since string
|
||||
until string
|
||||
utc bool
|
||||
machines []string
|
||||
}
|
||||
|
||||
func NewLogsCommand(groupID string) *cobra.Command {
|
||||
var options logsOptions
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs [SERVICE...]",
|
||||
Aliases: []string{"log"},
|
||||
Short: "View service logs.",
|
||||
Long: `View logs from all replicas of the specified service(s) across all machines in the cluster.
|
||||
|
||||
If no services are specified, streams logs from all services defined in the Compose file
|
||||
(compose.yaml by default or the file(s) specified with --file).`,
|
||||
Example: ` # View recent logs for a service.
|
||||
uc logs web
|
||||
|
||||
# Stream logs in real-time (follow mode).
|
||||
uc logs -f web
|
||||
|
||||
# View logs from multiple services.
|
||||
uc logs web api db
|
||||
|
||||
# View logs from all services in compose.yaml.
|
||||
uc logs
|
||||
|
||||
# Show last 20 lines per replica (default is 100).
|
||||
uc logs -n 20 web
|
||||
|
||||
# Show all logs without line limit.
|
||||
uc logs -n all web
|
||||
|
||||
# View logs from a specific time range.
|
||||
uc logs --since 3h --until 1h30m web
|
||||
|
||||
# View logs only from replicas running on specific machines.
|
||||
uc logs -m machine1,machine2 web api`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return runLogs(cmd.Context(), uncli, args, options)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVar(&options.files, "file", nil,
|
||||
"One or more Compose files to load service names from when no services are specified. (default compose.yaml)")
|
||||
cmd.Flags().BoolVarP(&options.follow, "follow", "f", false,
|
||||
"Continually stream new logs.")
|
||||
cmd.Flags().StringSliceVarP(&options.machines, "machine", "m", nil,
|
||||
"Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.")
|
||||
cmd.Flags().StringVar(&options.since, "since", "",
|
||||
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||
"Examples:\n"+
|
||||
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
|
||||
" --since 1h Relative duration (1 hour ago)\n"+
|
||||
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
|
||||
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
|
||||
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
||||
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
|
||||
cmd.Flags().StringVarP(&options.tail, "tail", "n", "100",
|
||||
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.")
|
||||
cmd.Flags().StringVar(&options.until, "until", "",
|
||||
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||
"See --since for examples.")
|
||||
cmd.Flags().BoolVar(&options.utc, "utc", false,
|
||||
"Print timestamps in UTC instead of local timezone.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logsOptions) error {
|
||||
// If no services specified, try to load them from the Compose file(s).
|
||||
fromCompose := false
|
||||
if len(serviceNames) == 0 {
|
||||
fromCompose = true
|
||||
project, err := compose.LoadProject(ctx, opts.files)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Compose file(s): %w", err)
|
||||
}
|
||||
// View logs for all services, including disabled by inactive profiles.
|
||||
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...)
|
||||
if len(serviceNames) == 0 {
|
||||
return errors.New("no services found in Compose file(s)")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tail option.
|
||||
tail := -1
|
||||
if opts.tail != "all" {
|
||||
tailInt, err := strconv.Atoi(opts.tail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --tail value '%s': %w", opts.tail, err)
|
||||
}
|
||||
tail = tailInt
|
||||
}
|
||||
|
||||
c, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
logsOpts := api.ServiceLogsOptions{
|
||||
Follow: opts.follow,
|
||||
Tail: tail,
|
||||
Since: opts.since,
|
||||
Until: opts.until,
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||
}
|
||||
|
||||
// Collect log streams from all services. When service names come from a Compose file,
|
||||
// skip the ones that are not found in the cluster (they may have been removed or not deployed yet).
|
||||
machineIDsSet := mapset.NewSet[string]()
|
||||
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames))
|
||||
var foundServices, notFoundServices []string
|
||||
|
||||
for _, serviceName := range serviceNames {
|
||||
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNotFound) && fromCompose {
|
||||
notFoundServices = append(notFoundServices, serviceName)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err)
|
||||
}
|
||||
svcStreams = append(svcStreams, ch)
|
||||
foundServices = append(foundServices, serviceName)
|
||||
|
||||
machineIDs := svc.MachineIDs()
|
||||
machineIDsSet.Append(machineIDs...)
|
||||
}
|
||||
|
||||
if fromCompose {
|
||||
if len(foundServices) == 0 {
|
||||
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
|
||||
strings.Join(opts.files, ", "))
|
||||
}
|
||||
serviceNames = foundServices
|
||||
|
||||
for _, name := range notFoundServices {
|
||||
client.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
|
||||
}
|
||||
}
|
||||
|
||||
var stream <-chan api.ServiceLogEntry
|
||||
if len(serviceNames) == 1 {
|
||||
stream = svcStreams[0]
|
||||
} else {
|
||||
// Merge all service streams into a single sorted stream without stall detection as its handled per-service.
|
||||
merger := client.NewLogMerger(svcStreams, client.LogMergerOptions{})
|
||||
stream = merger.Stream()
|
||||
}
|
||||
|
||||
// Fetch machine names for all machines (machineIDsSet) service containers are running on.
|
||||
machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: machineIDsSet.ToSlice()})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
machineNames := make([]string, 0, len(machines))
|
||||
for _, m := range machines {
|
||||
machineNames = append(machineNames, m.Machine.Name)
|
||||
}
|
||||
|
||||
formatter := newLogFormatter(machineNames, serviceNames, opts.utc)
|
||||
|
||||
// Print merged logs.
|
||||
for entry := range stream {
|
||||
if entry.Err != nil {
|
||||
formatter.printError(entry)
|
||||
continue
|
||||
}
|
||||
formatter.printEntry(entry)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Available colors for machine/service differentiation.
|
||||
var colorPalette = []lipgloss.Color{
|
||||
lipgloss.Color("10"), // Bright green
|
||||
lipgloss.Color("11"), // Bright yellow
|
||||
lipgloss.Color("12"), // Bright blue
|
||||
lipgloss.Color("13"), // Bright magenta
|
||||
lipgloss.Color("14"), // Bright cyan
|
||||
lipgloss.Color("2"), // Green
|
||||
lipgloss.Color("3"), // Yellow
|
||||
lipgloss.Color("4"), // Blue
|
||||
lipgloss.Color("5"), // Magenta
|
||||
lipgloss.Color("6"), // Cyan
|
||||
}
|
||||
|
||||
// logFormatter handles formatting and printing of log entries with dynamic column alignment.
|
||||
type logFormatter struct {
|
||||
machineNames []string
|
||||
serviceNames []string
|
||||
|
||||
maxMachineWidth int
|
||||
maxServiceWidth int
|
||||
|
||||
utc bool
|
||||
}
|
||||
|
||||
func newLogFormatter(machineNames, serviceNames []string, utc bool) *logFormatter {
|
||||
slices.Sort(machineNames)
|
||||
slices.Sort(serviceNames)
|
||||
|
||||
maxMachineWidth := 0
|
||||
for _, name := range machineNames {
|
||||
if len(name) > maxMachineWidth {
|
||||
maxMachineWidth = len(name)
|
||||
}
|
||||
}
|
||||
|
||||
maxServiceWidth := 0
|
||||
for _, name := range serviceNames {
|
||||
if len(name) > maxServiceWidth {
|
||||
maxServiceWidth = len(name)
|
||||
}
|
||||
}
|
||||
|
||||
return &logFormatter{
|
||||
machineNames: machineNames,
|
||||
serviceNames: serviceNames,
|
||||
maxMachineWidth: maxMachineWidth,
|
||||
maxServiceWidth: maxServiceWidth,
|
||||
utc: utc,
|
||||
}
|
||||
}
|
||||
|
||||
// formatTimestamp formats timestamp using local timezone or UTC if configured.
|
||||
func (f *logFormatter) formatTimestamp(t time.Time) string {
|
||||
if f.utc {
|
||||
t = t.UTC()
|
||||
} else {
|
||||
t = t.In(time.Local)
|
||||
}
|
||||
dimStyle := lipgloss.NewStyle().Faint(true)
|
||||
|
||||
return dimStyle.Render(t.Format(time.StampMilli))
|
||||
}
|
||||
|
||||
func (f *logFormatter) formatMachine(name string) string {
|
||||
style := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxMachineWidth - len(name))
|
||||
|
||||
if len(f.serviceNames) == 1 {
|
||||
// Machine name is coloured for single-service logs.
|
||||
i := slices.Index(f.machineNames, name)
|
||||
if i == -1 {
|
||||
f.machineNames = append(f.machineNames, name)
|
||||
i = len(f.machineNames) - 1
|
||||
}
|
||||
|
||||
style = style.Foreground(colorPalette[i%len(colorPalette)])
|
||||
}
|
||||
|
||||
return style.Render(name)
|
||||
}
|
||||
|
||||
func (f *logFormatter) formatServiceContainer(serviceName, containerID string) string {
|
||||
styleService := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxServiceWidth - len(serviceName))
|
||||
styleContainer := lipgloss.NewStyle().Faint(true)
|
||||
|
||||
if len(f.serviceNames) > 1 {
|
||||
// Service name is coloured for multi-service logs.
|
||||
i := slices.Index(f.serviceNames, serviceName)
|
||||
if i == -1 {
|
||||
f.serviceNames = append(f.serviceNames, serviceName)
|
||||
i = len(f.serviceNames) - 1
|
||||
}
|
||||
|
||||
styleService = styleService.Foreground(colorPalette[i%len(colorPalette)])
|
||||
}
|
||||
|
||||
return styleService.Render(serviceName) + styleContainer.Render("["+containerID[:5]+"]")
|
||||
}
|
||||
|
||||
// printEntry prints a single log entry with proper formatting.
|
||||
func (f *logFormatter) printEntry(entry api.ServiceLogEntry) {
|
||||
if entry.Stream != api.LogStreamStdout && entry.Stream != api.LogStreamStderr {
|
||||
return
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
|
||||
// Timestamp
|
||||
output.WriteString(f.formatTimestamp(entry.Timestamp))
|
||||
output.WriteString(" ")
|
||||
|
||||
// Machine name
|
||||
output.WriteString(f.formatMachine(entry.Metadata.MachineName))
|
||||
output.WriteString(" ")
|
||||
|
||||
// Service[container_id]
|
||||
output.WriteString(f.formatServiceContainer(entry.Metadata.ServiceName, entry.Metadata.ContainerID))
|
||||
output.WriteString(" ")
|
||||
|
||||
// Message
|
||||
output.Write(entry.Message)
|
||||
|
||||
// Print to appropriate stream.
|
||||
if entry.Stream == api.LogStreamStderr {
|
||||
fmt.Fprint(os.Stderr, output.String())
|
||||
} else {
|
||||
fmt.Print(output.String())
|
||||
}
|
||||
}
|
||||
|
||||
// printError prints an error entry (e.g., stalled stream warning).
|
||||
func (f *logFormatter) printError(entry api.ServiceLogEntry) {
|
||||
if entry.Metadata.ContainerID != "" {
|
||||
msg := fmt.Sprintf("WARNING: log stream from %s[%s] on machine '%s'",
|
||||
entry.Metadata.ServiceName,
|
||||
stringid.TruncateID(entry.Metadata.ContainerID),
|
||||
entry.Metadata.MachineName)
|
||||
|
||||
if errors.Is(entry.Err, api.ErrLogStreamStalled) {
|
||||
msg += " stopped responding"
|
||||
} else {
|
||||
msg += fmt.Sprintf(": %v", entry.Err)
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow
|
||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
||||
} else {
|
||||
msg := fmt.Sprintf("ERROR: %v", entry.Err)
|
||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) // Bold bright red
|
||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
||||
}
|
||||
}
|
||||
+27
-20
@@ -4,34 +4,31 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
// TODO(lhf): rename to context
|
||||
var cluster string
|
||||
func NewListCommand(groupID string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List services.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(cmd.Context(), uncli, cluster)
|
||||
return list(cmd.Context(), uncli)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
cmd.Flags().StringVarP(
|
||||
&cluster, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
client, err := uncli.ConnectCluster(ctx, clusterName)
|
||||
func list(ctx context.Context, uncli *cli.CLI) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
@@ -42,15 +39,15 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
return fmt.Errorf("list services: %w", err)
|
||||
}
|
||||
|
||||
serviceNames := make(map[string]struct{}, len(services))
|
||||
// Sort services by name.
|
||||
haveDuplicateNames := false
|
||||
for _, svc := range services {
|
||||
if _, exists := serviceNames[svc.Name]; exists {
|
||||
slices.SortFunc(services, func(a, b api.Service) int {
|
||||
if a.Name == b.Name {
|
||||
haveDuplicateNames = true
|
||||
break
|
||||
}
|
||||
serviceNames[svc.Name] = struct{}{}
|
||||
return strings.Compare(a.ID, b.ID)
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
// Print the list of services in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
@@ -61,19 +58,29 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tENDPOINTS"); err != nil {
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tIMAGE\tENDPOINTS"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
for _, s := range services {
|
||||
endpointsSlice := s.Endpoints()
|
||||
endpoints := strings.Join(endpointsSlice, ", ")
|
||||
images := strings.Join(s.Images(), ", ")
|
||||
endpoints := strings.Join(s.Endpoints(), ", ")
|
||||
|
||||
// If no endpoints from ports, check if the service uses custom Caddy config.
|
||||
if endpoints == "" {
|
||||
for _, ctr := range s.Containers {
|
||||
if ctr.Container.ServiceSpec.CaddyConfig() != "" {
|
||||
endpoints = "(custom Caddy config)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if haveDuplicateNames {
|
||||
if _, 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 {
|
||||
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n",
|
||||
s.Name, s.Mode, len(s.Containers), images, endpoints); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,31 +11,32 @@ import (
|
||||
|
||||
type rmOptions struct {
|
||||
services []string
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewRmCommand() *cobra.Command {
|
||||
func NewRmCommand(groupID string) *cobra.Command {
|
||||
opts := rmOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm SERVICE [SERVICE...]",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Remove one or more services.",
|
||||
Long: `Remove one or more services.
|
||||
|
||||
The volumes used by the services are preserved and should be removed separately
|
||||
with 'uc volume rm'. Anonymous Docker volumes (automatically created from VOLUME
|
||||
directives in image Dockerfiles) are automatically removed with their containers.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.services = args
|
||||
return rm(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
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)
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
@@ -50,5 +51,5 @@ func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
||||
}, uncli.ProgressOut(), "Removing service "+s)
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,14 +8,18 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "service",
|
||||
Aliases: []string{"svc"},
|
||||
Short: "Manage services in an Uncloud cluster.",
|
||||
Short: "Manage services in the cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewInspectCommand(),
|
||||
NewListCommand(),
|
||||
NewRmCommand(),
|
||||
NewRunCommand(),
|
||||
NewScaleCommand(),
|
||||
NewExecCommand(""),
|
||||
NewInspectCommand(""),
|
||||
NewListCommand(""),
|
||||
NewLogsCommand(""),
|
||||
NewRmCommand(""),
|
||||
NewRunCommand(""),
|
||||
NewScaleCommand(""),
|
||||
NewStartCommand(""),
|
||||
NewStopCommand(""),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -34,11 +34,9 @@ type runOptions struct {
|
||||
replicas uint
|
||||
user string
|
||||
volumes []string
|
||||
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewRunCommand() *cobra.Command {
|
||||
func NewRunCommand(groupID string) *cobra.Command {
|
||||
opts := runOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -56,6 +54,7 @@ func NewRunCommand() *cobra.Command {
|
||||
|
||||
return run(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
|
||||
@@ -112,11 +111,6 @@ func NewRunCommand() *cobra.Command {
|
||||
" -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
|
||||
}
|
||||
|
||||
@@ -126,7 +120,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -15,10 +15,9 @@ import (
|
||||
type scaleOptions struct {
|
||||
service string
|
||||
replicas uint
|
||||
cluster string
|
||||
}
|
||||
|
||||
func NewScaleCommand() *cobra.Command {
|
||||
func NewScaleCommand(groupID string) *cobra.Command {
|
||||
opts := scaleOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "scale SERVICE REPLICAS",
|
||||
@@ -37,13 +36,9 @@ func NewScaleCommand() *cobra.Command {
|
||||
|
||||
return scale(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.cluster, "context", "c", "",
|
||||
"Name of the cluster context. (default is the current context)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -57,7 +52,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
||||
)
|
||||
}
|
||||
|
||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//nolint:dupl
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type startOptions struct {
|
||||
services []string
|
||||
}
|
||||
|
||||
func NewStartCommand(groupID string) *cobra.Command {
|
||||
opts := startOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "start SERVICE [SERVICE...]",
|
||||
Short: "Start one or more services.",
|
||||
Long: `Start one or more previously stopped services.
|
||||
|
||||
Starts all containers of the specified service(s) across all machines in the cluster.
|
||||
Services can be specified by name or ID.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.services = args
|
||||
return start(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func start(ctx context.Context, uncli *cli.CLI, opts startOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
for _, s := range opts.services {
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err = client.StartService(ctx, s); err != nil {
|
||||
return fmt.Errorf("start service '%s': %w", s, err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), "Starting service "+s)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//nolint:dupl
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type stopOptions struct {
|
||||
services []string
|
||||
signal string
|
||||
timeout int
|
||||
}
|
||||
|
||||
func NewStopCommand(groupID string) *cobra.Command {
|
||||
opts := stopOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop SERVICE [SERVICE...]",
|
||||
Short: "Stop one or more services.",
|
||||
Long: `Stop one or more running services.
|
||||
|
||||
Gracefully stops all containers of the specified service(s) across all machines in the cluster.
|
||||
Services can be specified by name or ID. Stopped services can be restarted with 'uc start'.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
opts.services = args
|
||||
return stop(cmd.Context(), uncli, opts)
|
||||
},
|
||||
GroupID: groupID,
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.signal, "signal", "s", "",
|
||||
"Signal to send to each container's main process.\n"+
|
||||
"Can be a signal name (SIGTERM, SIGINT, SIGHUP, etc.) or a number. (default SIGTERM)")
|
||||
cmd.Flags().IntVarP(&opts.timeout, "timeout", "t", 10,
|
||||
"Seconds to wait for each container to stop gracefully before forcibly killing it with SIGKILL.\n"+
|
||||
"Use -1 to wait indefinitely.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func stop(ctx context.Context, uncli *cli.CLI, opts stopOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
stopOpts := container.StopOptions{
|
||||
Signal: opts.signal,
|
||||
Timeout: &opts.timeout,
|
||||
}
|
||||
|
||||
for _, s := range opts.services {
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err = client.StopService(ctx, s, stopOpts); err != nil {
|
||||
return fmt.Errorf("stop service '%s': %w", s, err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), "Stopping service "+s)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -19,7 +19,6 @@ type createOptions struct {
|
||||
driverOpts []string
|
||||
labels []string
|
||||
machine string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
@@ -50,14 +49,12 @@ func NewCreateCommand() *cobra.Command {
|
||||
"Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.")
|
||||
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)
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
type inspectOptions struct {
|
||||
machine string
|
||||
context string
|
||||
}
|
||||
|
||||
func NewInspectCommand() *cobra.Command {
|
||||
@@ -32,14 +31,12 @@ func NewInspectCommand() *cobra.Command {
|
||||
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)
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
type listOptions struct {
|
||||
machines []string
|
||||
quiet bool
|
||||
context string
|
||||
}
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
@@ -38,14 +37,11 @@ func NewListCommand() *cobra.Command {
|
||||
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)
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ type removeOptions struct {
|
||||
force bool
|
||||
machines []string
|
||||
yes bool
|
||||
context string
|
||||
}
|
||||
|
||||
func NewRemoveCommand() *cobra.Command {
|
||||
@@ -41,14 +40,11 @@ func NewRemoveCommand() *cobra.Command {
|
||||
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)
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "volume",
|
||||
Short: "Manage volumes in an Uncloud cluster.",
|
||||
Short: "Manage volumes in the cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewCreateCommand(),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package wg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/docker/go-units"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "wg",
|
||||
Short: "Inspect WireGuard network",
|
||||
}
|
||||
cmd.AddCommand(newShowCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
type showOptions struct {
|
||||
machine string
|
||||
}
|
||||
|
||||
func newShowCommand() *cobra.Command {
|
||||
opts := showOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show WireGuard network configuration for a machine.",
|
||||
Long: "Show the WireGuard network configuration for the machine currently connected to " +
|
||||
"(or specified by the global --connect flag).",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return runShow(cmd.Context(), uncli, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||
"Name or ID of the machine to show the configuration for. (default is connected machine)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if opts.machine != "" {
|
||||
// Proxy requests to the specified machine.
|
||||
ctx, _, err = client.ProxyMachinesContext(ctx, []string{opts.machine})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.MachineClient.InspectWireGuardNetwork(ctx, nil)
|
||||
if err != nil {
|
||||
if status.Code(err) == codes.Unimplemented {
|
||||
return fmt.Errorf("inspect WireGuard network: "+
|
||||
"make sure the target machine is running uncloudd daemon version >= 0.16.0: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
machines, err := client.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
machinesNamesByPublicKey := make(map[string]string)
|
||||
for _, m := range machines {
|
||||
publicKey := wgtypes.Key(m.Machine.Network.PublicKey).String()
|
||||
machinesNamesByPublicKey[publicKey] = m.Machine.Name
|
||||
}
|
||||
|
||||
// Fetch the machine's name for more descriptive output
|
||||
inspectResp, err := client.MachineClient.InspectMachine(ctx, nil)
|
||||
if err == nil {
|
||||
fmt.Printf("Machine name: %s\n", inspectResp.Machines[0].Machine.Name)
|
||||
}
|
||||
|
||||
fmt.Printf("WireGuard interface: %s\n", resp.InterfaceName)
|
||||
fmt.Printf("WireGuard public key: %s\n", wgtypes.Key(resp.PublicKey).String())
|
||||
fmt.Printf("WireGuard port: %d\n", resp.ListenPort)
|
||||
fmt.Println()
|
||||
|
||||
if len(resp.Peers) == 0 {
|
||||
fmt.Println("No WireGuard peers configured.")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
if _, err = fmt.Fprintln(tw, "PEER\tPUBLIC KEY\tENDPOINT\tHANDSHAKE\tRECEIVED\tSENT\tALLOWED IPS"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
|
||||
for _, peer := range resp.Peers {
|
||||
machineName, ok := machinesNamesByPublicKey[wgtypes.Key(peer.PublicKey).String()]
|
||||
if !ok {
|
||||
machineName = "(unknown)"
|
||||
}
|
||||
|
||||
lastHandshake := ""
|
||||
if peer.LastHandshakeTime != nil {
|
||||
lastHandshake = time.Since(peer.LastHandshakeTime.AsTime()).Round(time.Second).String() + " ago"
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(
|
||||
tw,
|
||||
"%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
machineName,
|
||||
wgtypes.Key(peer.PublicKey).String(),
|
||||
peer.Endpoint,
|
||||
lastHandshake,
|
||||
units.HumanSize(float64(peer.ReceiveBytes)),
|
||||
units.HumanSize(float64(peer.TransmitBytes)),
|
||||
strings.Join(peer.AllowedIps, ", "),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newDialStdioCommand() *cobra.Command {
|
||||
var socketPath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "dial-stdio",
|
||||
Short: "Proxy stdin/stdout to the Uncloud API socket",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runDialStdio(cmd.Context(), socketPath, os.Stdin, os.Stdout)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultUncloudSockPath,
|
||||
"Path to the Uncloud API socket")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// halfReadCloser is the read side of a half-duplex connection.
|
||||
type halfReadCloser interface {
|
||||
io.Reader
|
||||
CloseRead() error
|
||||
}
|
||||
|
||||
// halfWriteCloser is the write side of a half-duplex connection.
|
||||
type halfWriteCloser interface {
|
||||
io.Writer
|
||||
CloseWrite() error
|
||||
}
|
||||
|
||||
// halfReadCloserWrapper wraps an io.ReadCloser to implement halfReadCloser.
|
||||
type halfReadCloserWrapper struct {
|
||||
io.ReadCloser
|
||||
}
|
||||
|
||||
func (x *halfReadCloserWrapper) CloseRead() error {
|
||||
return x.Close()
|
||||
}
|
||||
|
||||
// halfWriteCloserWrapper wraps an io.WriteCloser to implement halfWriteCloser.
|
||||
type halfWriteCloserWrapper struct {
|
||||
io.WriteCloser
|
||||
}
|
||||
|
||||
func (x *halfWriteCloserWrapper) CloseWrite() error {
|
||||
return x.Close()
|
||||
}
|
||||
|
||||
func runDialStdio(ctx context.Context, socketPath string, stdin io.Reader, stdout io.Writer) error {
|
||||
// Connect to the unix socket.
|
||||
var dialer net.Dialer
|
||||
conn, err := dialer.DialContext(ctx, "unix", socketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to socket %q: %w", socketPath, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Wrap stdin/stdout to support half-closing.
|
||||
var stdinCloser halfReadCloser
|
||||
if c, ok := stdin.(halfReadCloser); ok {
|
||||
stdinCloser = c
|
||||
} else if c, ok := stdin.(io.ReadCloser); ok {
|
||||
stdinCloser = &halfReadCloserWrapper{c}
|
||||
}
|
||||
|
||||
var stdoutCloser halfWriteCloser
|
||||
if c, ok := stdout.(halfWriteCloser); ok {
|
||||
stdoutCloser = c
|
||||
} else if c, ok := stdout.(io.WriteCloser); ok {
|
||||
stdoutCloser = &halfWriteCloserWrapper{c}
|
||||
}
|
||||
|
||||
// Copy data bidirectionally between stdin/stdout and the socket.
|
||||
stdin2socket := make(chan error, 1)
|
||||
socket2stdout := make(chan error, 1)
|
||||
|
||||
// Copy from stdin to socket.
|
||||
go func() {
|
||||
_, err := io.Copy(conn, stdin)
|
||||
stdin2socket <- err
|
||||
// Close write side of connection after stdin is done.
|
||||
if unixConn, ok := conn.(*net.UnixConn); ok {
|
||||
unixConn.CloseWrite()
|
||||
}
|
||||
if stdinCloser != nil {
|
||||
stdinCloser.CloseRead()
|
||||
}
|
||||
}()
|
||||
|
||||
// Copy from socket to stdout.
|
||||
go func() {
|
||||
_, err := io.Copy(stdout, conn)
|
||||
socket2stdout <- err
|
||||
// Close read side of connection after socket is done sending.
|
||||
if unixConn, ok := conn.(*net.UnixConn); ok {
|
||||
unixConn.CloseRead()
|
||||
}
|
||||
if stdoutCloser != nil {
|
||||
stdoutCloser.CloseWrite()
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-stdin2socket:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// wait for stdout
|
||||
err = <-socket2stdout
|
||||
case err = <-socket2stdout:
|
||||
// return immediately, matching Docker's approach
|
||||
// (stdin is never closed when TTY)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -42,6 +42,9 @@ func main() {
|
||||
"Directory for storing persistent machine state")
|
||||
_ = cmd.MarkFlagDirname("data-dir")
|
||||
|
||||
// Add dial-stdio subcommand.
|
||||
cmd.AddCommand(newDialStdioCommand())
|
||||
|
||||
// ctx is canceled when the daemon command is interrupted.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
module github.com/psviderski/uncloud
|
||||
|
||||
go 1.23.0
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/alecthomas/chroma/v2 v2.20.0
|
||||
github.com/caddyserver/caddy/v2 v2.8.4
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
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/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/compose-spec/compose-go/v2 v2.9.0
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/containerd/platforms v1.0.0-rc.1
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||
github.com/deckarep/golang-set/v2 v2.8.0
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||
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/cli v28.5.0+incompatible
|
||||
github.com/docker/compose/v2 v2.40.0
|
||||
github.com/docker/docker v28.5.0+incompatible
|
||||
github.com/docker/go-connections v0.6.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
|
||||
@@ -37,45 +42,67 @@ require (
|
||||
github.com/lmittmann/tint v1.0.5
|
||||
github.com/miekg/dns v1.1.65
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/moby/term v0.5.0
|
||||
github.com/moby/term v0.5.2
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/opencontainers/go-digest v1.0.0
|
||||
github.com/opencontainers/image-spec v1.1.0
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/psviderski/unregistry v0.4.1
|
||||
github.com/siderolabs/discovery-api v0.1.4
|
||||
github.com/siderolabs/discovery-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
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
go.uber.org/zap v1.27.0
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||
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.org/x/crypto v0.41.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/sync v0.17.0
|
||||
golang.org/x/sys v0.36.0
|
||||
golang.org/x/term v0.34.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||
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
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a
|
||||
google.golang.org/grpc v1.74.2
|
||||
google.golang.org/protobuf v1.36.9
|
||||
modernc.org/sqlite v1.36.3
|
||||
tags.cncf.io/container-device-interface v1.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/kms v1.19.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
||||
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/OneOfOne/xxhash v1.2.8 // indirect
|
||||
github.com/Microsoft/hcsshim v0.13.0 // indirect
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.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/aws/aws-sdk-go-v2 v1.30.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
|
||||
github.com/aws/smithy-go v1.20.3 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bgentry/speakeasy v0.2.0 // indirect
|
||||
@@ -85,68 +112,86 @@ require (
|
||||
github.com/catppuccin/go v0.2.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/x/ansi v0.3.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // 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/cgroups/v3 v3.0.5 // indirect
|
||||
github.com/containerd/console v1.0.5 // indirect
|
||||
github.com/containerd/containerd/api v1.9.0 // indirect
|
||||
github.com/containerd/containerd/v2 v2.1.4 // indirect
|
||||
github.com/containerd/continuity v0.4.5 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/fifo v1.1.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/containerd/plugin v1.0.0 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||
github.com/containerd/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/containerd/ttrpc v1.2.7 // indirect
|
||||
github.com/containerd/typeurl/v2 v2.2.3 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||
github.com/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/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/distribution/v3 v3.0.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/docker/buildx v0.18.0 // indirect
|
||||
github.com/docker/buildx v0.29.1 // indirect
|
||||
github.com/docker/cli-docs-tool v0.10.0 // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
||||
github.com/docker/go-metrics v0.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fatih/color v1.17.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsevents v0.2.0 // indirect
|
||||
github.com/fvbommel/sortorder v1.1.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
||||
github.com/go-kit/kit v0.13.0 // indirect
|
||||
github.com/go-kit/log v0.2.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/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-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/golang/glog v1.2.5 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/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/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||
github.com/google/gnostic-models v0.6.8 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/handlers v1.5.2 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
|
||||
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
@@ -154,13 +199,15 @@ require (
|
||||
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/arc/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/logutils v1.0.0 // indirect
|
||||
github.com/hashicorp/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/in-toto/in-toto-golang v0.9.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect
|
||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||
github.com/ipfs/go-block-format v0.2.0 // indirect
|
||||
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
|
||||
@@ -178,8 +225,12 @@ require (
|
||||
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/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
@@ -189,6 +240,7 @@ require (
|
||||
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/mailru/easyjson v0.7.7 // 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
|
||||
@@ -208,19 +260,26 @@ require (
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/buildkit v0.17.2 // indirect
|
||||
github.com/moby/buildkit v0.25.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/locker v1.0.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/spdystream v0.5.0 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/sys/capability v0.4.0 // indirect
|
||||
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/signal v0.7.1 // indirect
|
||||
github.com/moby/sys/user v0.3.0 // indirect
|
||||
github.com/moby/sys/symlink v0.3.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
github.com/multiformats/go-multiaddr v0.13.0 // indirect
|
||||
@@ -231,94 +290,130 @@ require (
|
||||
github.com/multiformats/go-multistream v0.5.0 // indirect
|
||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||
github.com/opencontainers/runtime-spec v1.2.1 // indirect
|
||||
github.com/opencontainers/selinux v1.12.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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_golang v1.22.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.61.0 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
|
||||
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
|
||||
github.com/redis/go-redis/v9 v9.7.3 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/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/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.6.0 // indirect
|
||||
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
|
||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||
github.com/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/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // 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/pkcs7 v0.1.1 // 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/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
|
||||
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c // indirect
|
||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
|
||||
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
|
||||
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect
|
||||
github.com/tonistiigi/fsutil v0.0.0-20250605211040-586307ad452f // indirect
|
||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect
|
||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
||||
github.com/tonistiigi/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/urfave/cli v1.22.17 // indirect
|
||||
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zclconf/go-cty v1.17.0 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.etcd.io/bbolt v1.3.11 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.opencensus.io v0.24.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.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||
go.step.sm/cli-utils v0.9.0 // indirect
|
||||
go.step.sm/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/mock v0.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap/exp v0.2.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
|
||||
golang.org/x/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.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||
howett.net/plist v1.0.0 // indirect
|
||||
k8s.io/api v0.32.3 // indirect
|
||||
k8s.io/apimachinery v0.32.3 // indirect
|
||||
k8s.io/client-go v0.32.3 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
|
||||
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
|
||||
lukechampine.com/blake3 v1.3.0 // indirect
|
||||
modernc.org/libc v1.61.13 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
|
||||
+176
-153
@@ -2,175 +2,198 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
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"
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/flags"
|
||||
composeapi "github.com/docker/compose/v2/pkg/api"
|
||||
composev2 "github.com/docker/compose/v2/pkg/compose"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||
)
|
||||
|
||||
type BuildOptions struct {
|
||||
Files []string
|
||||
Profiles []string
|
||||
Services []string
|
||||
Push bool
|
||||
// BuildServicesOptions contains options for building services in a Compose project.
|
||||
type BuildServicesOptions struct {
|
||||
// BuildArgs sets build-time variables for services. Used in Dockerfiles that declare variables with ARG.
|
||||
BuildArgs []string
|
||||
// Check the build configuration for services without building them.
|
||||
Check bool
|
||||
// Deps enables to also build services declared as dependencies of the selected Services.
|
||||
Deps bool
|
||||
// NoCache disables the use of cache when building images.
|
||||
NoCache bool
|
||||
// Pull attempts to pull newer versions of the base images before building.
|
||||
Pull bool
|
||||
// Services specifies which services to build. If empty, all services with a build config are built.
|
||||
Services []string
|
||||
|
||||
// Push targets are mutually exclusive.
|
||||
// PushCluster uploads the built images to cluster machines after building.
|
||||
PushCluster bool
|
||||
// PushRegistry uploads the built images to external registries after building.
|
||||
PushRegistry bool
|
||||
|
||||
// Cluster-specific options (only used if PushCluster is true).
|
||||
// Machines is a list of machine names or IDs to push the image to. If empty, images are pushed to all machines.
|
||||
Machines []string
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// BuildServices builds images for services in the Compose project.
|
||||
func (cli *CLI) BuildServices(ctx context.Context, project *composetypes.Project, opts BuildServicesOptions) error {
|
||||
// Validate push targets.
|
||||
if opts.PushCluster && opts.PushRegistry {
|
||||
return fmt.Errorf("cannot specify both PushCluster and PushRegistry: choose one push target")
|
||||
}
|
||||
|
||||
// Build service images using Compose implementation.
|
||||
dockerCli, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docker client: %w", err)
|
||||
}
|
||||
// Initialise the Docker CLI with default options.
|
||||
if err = dockerCli.Initialize(flags.NewClientOptions()); err != nil {
|
||||
return fmt.Errorf("initialise docker client: %w", err)
|
||||
}
|
||||
|
||||
composeService := composev2.NewComposeService(dockerCli)
|
||||
buildOpts := composeapi.BuildOptions{
|
||||
Args: composetypes.NewMappingWithEquals(opts.BuildArgs),
|
||||
Check: opts.Check,
|
||||
Deps: opts.Deps,
|
||||
NoCache: opts.NoCache,
|
||||
Pull: opts.Pull,
|
||||
Push: opts.PushRegistry,
|
||||
Services: opts.Services,
|
||||
}
|
||||
|
||||
if err = composeService.Build(ctx, project, buildOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !opts.PushCluster {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Push built service images to cluster machines.
|
||||
builtServices, err := ServicesThatNeedBuild(project, opts.Services, opts.Deps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("determine built services: %w", err)
|
||||
}
|
||||
if len(builtServices) == 0 {
|
||||
// No services were built, nothing to push.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add a line break after the build output.
|
||||
fmt.Fprintln(cli.ProgressOut())
|
||||
|
||||
clusterClient, err := cli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
|
||||
// Push one service image at a time.
|
||||
var errs []error
|
||||
for _, s := range builtServices {
|
||||
if s.Image == "" {
|
||||
// Skip services without an image name (shouldn't happen for services with build config).
|
||||
continue
|
||||
}
|
||||
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...")
|
||||
// Push to the specified machines falling back to service x-machines.
|
||||
// If none specified, push to *all* cluster machines.
|
||||
var pushOpts client.PushImageOptions
|
||||
|
||||
// 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)
|
||||
if len(opts.Machines) > 0 {
|
||||
pushOpts.Machines = opts.Machines
|
||||
} else if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
|
||||
pushOpts.Machines = machines
|
||||
}
|
||||
|
||||
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)
|
||||
if len(pushOpts.Machines) == 0 {
|
||||
pushOpts.AllMachines = true
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
boldStyle := lipgloss.NewStyle().Bold(true)
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
|
||||
return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
|
||||
}
|
||||
return nil
|
||||
}, cli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
|
||||
// Collect errors to try pushing all images.
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// ServicesThatNeedBuild returns a list of services that require building.
|
||||
// deps indicates whether to include services that are dependencies of the selected services.
|
||||
// Implementation is based on the logic from docker/compose/v2/pkg/compose/build.go.
|
||||
func ServicesThatNeedBuild(
|
||||
project *composetypes.Project, selectedServices []string, deps bool,
|
||||
) ([]composetypes.ServiceConfig, error) {
|
||||
servicesToBuild := make([]composetypes.ServiceConfig, 0, len(project.Services))
|
||||
|
||||
var policy composetypes.DependencyOption = composetypes.IgnoreDependencies
|
||||
if deps {
|
||||
policy = composetypes.IncludeDependencies
|
||||
}
|
||||
|
||||
// Also include services used as build.additional_contexts with service: prefix.
|
||||
selectedServices = includeAdditionalContextsServices(project, selectedServices)
|
||||
// Some build dependencies we just introduced may not be enabled, enable them.
|
||||
var err error
|
||||
if project, err = project.WithServicesEnabled(selectedServices...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if project, err = project.WithSelectedServices(selectedServices); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = project.ForEachService(selectedServices, func(serviceName string, service *composetypes.ServiceConfig) error {
|
||||
if service.Build != nil {
|
||||
servicesToBuild = append(servicesToBuild, *service)
|
||||
}
|
||||
return nil
|
||||
}, policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return servicesToBuild, nil
|
||||
}
|
||||
|
||||
// includeAdditionalContextsServices adds services referenced in build.additional_contexts to the list
|
||||
// of selected services.
|
||||
func includeAdditionalContextsServices(project *composetypes.Project, selectedServices []string) []string {
|
||||
servicesWithDependencies := mapset.NewSet(selectedServices...)
|
||||
for _, service := range selectedServices {
|
||||
s, ok := project.Services[service]
|
||||
if !ok {
|
||||
s = project.DisabledServices[service]
|
||||
}
|
||||
if s.Build != nil {
|
||||
for _, target := range s.Build.AdditionalContexts {
|
||||
if name, found := strings.CutPrefix(target, composetypes.ServicePrefix); found {
|
||||
servicesWithDependencies.Add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if servicesWithDependencies.Cardinality() > len(selectedServices) {
|
||||
return includeAdditionalContextsServices(project, servicesWithDependencies.ToSlice())
|
||||
}
|
||||
|
||||
return servicesWithDependencies.ToSlice()
|
||||
}
|
||||
|
||||
+150
-57
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -32,12 +30,14 @@ const (
|
||||
type CLI struct {
|
||||
Config *config.Config
|
||||
conn *config.MachineConnection
|
||||
contextOverride string
|
||||
}
|
||||
|
||||
// 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 a non-empty context name is given, it will override the current default.
|
||||
func New(configPath string, conn *config.MachineConnection, contextName string) (*CLI, error) {
|
||||
if conn != nil {
|
||||
return &CLI{conn: conn}, nil
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
|
||||
|
||||
return &CLI{
|
||||
Config: cfg,
|
||||
contextOverride: contextName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -70,11 +71,30 @@ func (cli *CLI) SetCurrentContext(name string) error {
|
||||
return cli.Config.Save()
|
||||
}
|
||||
|
||||
// ConnectCluster connects to a cluster using the given context name or the current context if not specified.
|
||||
func (cli *CLI) GetContextOverrideOrCurrent() string {
|
||||
contextName := cli.contextOverride
|
||||
if contextName == "" {
|
||||
contextName = cli.Config.CurrentContext
|
||||
}
|
||||
return contextName
|
||||
}
|
||||
|
||||
// ConnectCluster connects to a cluster using the context override or the current context if not specified.
|
||||
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||
func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client.Client, error) {
|
||||
func (cli *CLI) ConnectCluster(ctx context.Context) (*client.Client, error) {
|
||||
return cli.ConnectClusterWithOptions(ctx, ConnectOptions{
|
||||
// Default to showing progress for CLI usage.
|
||||
ShowProgress: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ConnectClusterWithOptions connects to a cluster with the given options.
|
||||
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||
// If the CLI has an override context, it is used instead of the current default.
|
||||
// Options are useful when using the CLI as a library where you may want to disable visual feedback.
|
||||
func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, opts ConnectOptions) (*client.Client, error) {
|
||||
if cli.conn != nil {
|
||||
return connectCluster(ctx, *cli.conn)
|
||||
return ConnectCluster(ctx, *cli.conn, opts)
|
||||
}
|
||||
|
||||
if len(cli.Config.Contexts) == 0 {
|
||||
@@ -84,6 +104,8 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client
|
||||
cli.Config.Path(),
|
||||
)
|
||||
}
|
||||
|
||||
contextName := cli.contextOverride
|
||||
if contextName == "" {
|
||||
// If the cluster is not specified, use the current cluster if set.
|
||||
if cli.Config.CurrentContext == "" {
|
||||
@@ -116,38 +138,20 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
|
||||
conn := cfg.Connections[0]
|
||||
|
||||
c, err := connectCluster(ctx, conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||
}
|
||||
|
||||
// Try each connection in order until one succeeds.
|
||||
var lastErr error
|
||||
for _, conn := range cfg.Connections {
|
||||
c, err := ConnectCluster(ctx, conn, opts)
|
||||
if err == nil {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||
if conn.SSH != "" {
|
||||
user, host, port, err := conn.SSH.Parse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
||||
}
|
||||
|
||||
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
||||
|
||||
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))
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return nil, errors.New("connection configuration is invalid")
|
||||
return nil, fmt.Errorf("failed to connect to cluster context '%s': "+
|
||||
"all connections (%d) in the Uncloud config (%s) failed; last error: %w",
|
||||
contextName, len(cfg.Connections), cli.Config.Path(), lastErr)
|
||||
}
|
||||
|
||||
type InitClusterOptions struct {
|
||||
@@ -156,7 +160,10 @@ type InitClusterOptions struct {
|
||||
Network netip.Prefix
|
||||
PublicIP *netip.Addr
|
||||
RemoteMachine *RemoteMachine
|
||||
SkipInstall bool
|
||||
Version string
|
||||
AutoConfirm bool
|
||||
WireguardEndpoints []*pb.IPPort
|
||||
}
|
||||
|
||||
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
|
||||
@@ -166,7 +173,7 @@ func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*clie
|
||||
return cli.initRemoteMachine(ctx, opts)
|
||||
}
|
||||
// TODO: implement local machine initialisation
|
||||
return nil, fmt.Errorf("local machine initialisation is not implemented yet")
|
||||
return nil, fmt.Errorf("local machine initialisation is not implemented yet. Please specify a remote machine")
|
||||
}
|
||||
|
||||
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||
@@ -175,7 +182,7 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,7 +199,12 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
||||
return nil, fmt.Errorf("inspect machine: %w", err)
|
||||
}
|
||||
if minfo.Id != "" {
|
||||
if err = promptResetMachine(ctx, machineClient.MachineClient); err != nil {
|
||||
if !opts.AutoConfirm {
|
||||
if err = promptResetMachine(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = resetAndWaitMachine(ctx, machineClient.MachineClient); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -211,6 +223,7 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
||||
req := &pb.InitClusterRequest{
|
||||
MachineName: opts.MachineName,
|
||||
Network: pb.NewIPPrefix(opts.Network),
|
||||
WireguardEndpoints: opts.WireguardEndpoints,
|
||||
}
|
||||
if opts.PublicIP != nil {
|
||||
if opts.PublicIP.IsValid() {
|
||||
@@ -237,8 +250,21 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
||||
|
||||
// Save the machine's SSH connection details in the context config.
|
||||
connCfg := config.MachineConnection{
|
||||
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||
MachineID: resp.Machine.Id,
|
||||
}
|
||||
if opts.RemoteMachine.UseSSHCLI {
|
||||
connCfg.SSHCLI = config.NewSSHDestination(
|
||||
opts.RemoteMachine.User,
|
||||
opts.RemoteMachine.Host,
|
||||
opts.RemoteMachine.Port,
|
||||
)
|
||||
} else {
|
||||
connCfg.SSH = config.NewSSHDestination(
|
||||
opts.RemoteMachine.User,
|
||||
opts.RemoteMachine.Host,
|
||||
opts.RemoteMachine.Port,
|
||||
)
|
||||
}
|
||||
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||
if err = cli.Config.Save(); err != nil {
|
||||
@@ -274,11 +300,13 @@ func (cli *CLI) newContextName(name string) (string, error) {
|
||||
}
|
||||
|
||||
type AddMachineOptions struct {
|
||||
Context string
|
||||
MachineName string
|
||||
PublicIP *netip.Addr
|
||||
RemoteMachine *RemoteMachine
|
||||
SkipInstall bool
|
||||
Version string
|
||||
AutoConfirm bool
|
||||
WireguardEndpoints []*pb.IPPort
|
||||
}
|
||||
|
||||
// AddMachine provisions a remote machine and adds it to the cluster. It returns a cluster client and a machine client.
|
||||
@@ -286,11 +314,8 @@ type AddMachineOptions struct {
|
||||
// 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)
|
||||
contextName := cli.GetContextOverrideOrCurrent()
|
||||
c, err := cli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||
}
|
||||
@@ -300,7 +325,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
}
|
||||
}()
|
||||
|
||||
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -311,6 +336,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
}()
|
||||
|
||||
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
||||
// TODO: refactor to use client.InspectMachine.
|
||||
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("inspect machine: %w", err)
|
||||
@@ -327,7 +353,12 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
return nil, nil, fmt.Errorf("machine is already a member of this cluster (%s)", minfo.Name)
|
||||
}
|
||||
|
||||
if err = promptResetMachine(ctx, machineClient.MachineClient); err != nil {
|
||||
if !opts.AutoConfirm {
|
||||
if err = promptResetMachine(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if err = resetAndWaitMachine(ctx, machineClient.MachineClient); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
@@ -352,11 +383,17 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
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.
|
||||
endpoints := make([]*pb.IPPort, len(token.Endpoints))
|
||||
// Register the machine in the cluster using its public key and WireGuard endpoints from the token,
|
||||
// or use the explicitly provided WireGuard endpoints if any.
|
||||
var endpoints []*pb.IPPort
|
||||
if len(opts.WireguardEndpoints) > 0 {
|
||||
endpoints = opts.WireguardEndpoints
|
||||
} else {
|
||||
endpoints = make([]*pb.IPPort, len(token.Endpoints))
|
||||
for i, addrPort := range token.Endpoints {
|
||||
endpoints[i] = pb.NewIPPort(addrPort)
|
||||
}
|
||||
}
|
||||
addReq := &pb.AddMachineRequest{
|
||||
Name: opts.MachineName,
|
||||
Network: &pb.NetworkConfig{
|
||||
@@ -378,6 +415,18 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
return nil, nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err)
|
||||
}
|
||||
|
||||
// Get the current store DB version from the cluster to pass to the join request.
|
||||
var storeDBVersion int64
|
||||
inspectResp, err := c.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
// TODO(lhf): remove Unimplemented check when v0.17.0 is released.
|
||||
if status.Convert(err).Code() != codes.Unimplemented {
|
||||
return nil, nil, fmt.Errorf("inspect current cluster machine: %w", err)
|
||||
}
|
||||
} else {
|
||||
storeDBVersion = inspectResp.Machines[0].StoreDbVersion
|
||||
}
|
||||
|
||||
// Get the most up-to-date list of other machines in the cluster to include them in the join request.
|
||||
machines, err := c.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -394,6 +443,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
joinReq := &pb.JoinClusterRequest{
|
||||
Machine: addResp.Machine,
|
||||
OtherMachines: otherMachines,
|
||||
MinStoreDbVersion: storeDBVersion,
|
||||
}
|
||||
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
|
||||
return nil, nil, fmt.Errorf("join cluster: %w", err)
|
||||
@@ -404,11 +454,21 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
|
||||
// Save the machine's SSH connection details in the context config.
|
||||
connCfg := config.MachineConnection{
|
||||
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||
MachineID: addResp.Machine.Id,
|
||||
}
|
||||
if contextName == "" {
|
||||
contextName = cli.Config.CurrentContext
|
||||
if opts.RemoteMachine.UseSSHCLI {
|
||||
connCfg.SSHCLI = config.NewSSHDestination(
|
||||
opts.RemoteMachine.User,
|
||||
opts.RemoteMachine.Host,
|
||||
opts.RemoteMachine.Port,
|
||||
)
|
||||
} else {
|
||||
connCfg.SSH = config.NewSSHDestination(
|
||||
opts.RemoteMachine.User,
|
||||
opts.RemoteMachine.Host,
|
||||
opts.RemoteMachine.Port,
|
||||
)
|
||||
}
|
||||
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||
if err = cli.Config.Save(); err != nil {
|
||||
@@ -418,15 +478,45 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
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.
|
||||
// provisionOrConnectRemoteMachine 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.
|
||||
// If skipInstall is true, the installation step is skipped, and it is assumed that the Uncloud daemon and dependencies
|
||||
// are already installed and running.
|
||||
// The remoteMachine.SSHKeyPath could be updated to the default SSH key path if it is not set and the SSH agent
|
||||
// authentication fails.
|
||||
func provisionRemoteMachine(
|
||||
ctx context.Context, remoteMachine *RemoteMachine, version string,
|
||||
func provisionOrConnectRemoteMachine(
|
||||
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
|
||||
) (*client.Client, error) {
|
||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||
// Use SSH CLI
|
||||
if remoteMachine.UseSSHCLI {
|
||||
exec := sshexec.NewSSHCLIRemote(
|
||||
remoteMachine.User,
|
||||
remoteMachine.Host,
|
||||
remoteMachine.Port,
|
||||
remoteMachine.KeyPath,
|
||||
)
|
||||
|
||||
if !skipInstall {
|
||||
if err := provisionMachine(ctx, exec, version); err != nil {
|
||||
return nil, fmt.Errorf("provision machine: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: remoteMachine.User,
|
||||
Host: remoteMachine.Host,
|
||||
Port: remoteMachine.Port,
|
||||
KeyPath: remoteMachine.KeyPath,
|
||||
}
|
||||
machineClient, err := client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to remote machine: %w", err)
|
||||
}
|
||||
return machineClient, nil
|
||||
}
|
||||
|
||||
// Use Go SSH
|
||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
||||
// If the SSH connection using SSH agent fails and no key path is provided, try to use the default SSH key.
|
||||
if err != nil && remoteMachine.KeyPath == "" {
|
||||
@@ -441,14 +531,17 @@ func provisionRemoteMachine(
|
||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
||||
)
|
||||
}
|
||||
|
||||
if !skipInstall {
|
||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||
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" {
|
||||
if remoteMachine.User == "root" || skipInstall {
|
||||
// Create a machine API client over the established SSH connection to the remote machine.
|
||||
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
@@ -9,52 +11,90 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultSSHUser = "root"
|
||||
DefaultSSHPort = 22
|
||||
)
|
||||
|
||||
type MachineConnection struct {
|
||||
SSH SSHDestination `yaml:"ssh,omitempty"`
|
||||
SSHCLI SSHDestination `yaml:"ssh_cli,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"`
|
||||
// Unix is the path to the machine's API unix socket.
|
||||
Unix string `yaml:"unix,omitempty"`
|
||||
Host string `yaml:"host,omitempty"`
|
||||
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
||||
MachineID string `yaml:"machine_id,omitempty"`
|
||||
}
|
||||
|
||||
func (c *MachineConnection) String() string {
|
||||
if c.SSH != "" {
|
||||
return "ssh://" + string(c.SSH)
|
||||
} else if c.SSHCLI != "" {
|
||||
return "ssh+cli://" + string(c.SSHCLI)
|
||||
} else if c.TCP != nil && c.TCP.IsValid() {
|
||||
return fmt.Sprintf("tcp://%s", c.TCP)
|
||||
} else if c.Unix != "" {
|
||||
return fmt.Sprintf("unix://%s", c.Unix)
|
||||
}
|
||||
return "unknown connection"
|
||||
}
|
||||
|
||||
func (c *MachineConnection) Validate() error {
|
||||
setCount := 0
|
||||
if c.SSH != "" {
|
||||
setCount++
|
||||
}
|
||||
if c.SSHCLI != "" {
|
||||
setCount++
|
||||
}
|
||||
if c.TCP != nil && c.TCP.IsValid() {
|
||||
setCount++
|
||||
}
|
||||
if c.Unix != "" {
|
||||
setCount++
|
||||
}
|
||||
|
||||
if setCount == 0 {
|
||||
return errors.New("no connection method specified (ssh, ssh_cli, tcp, or unix required)")
|
||||
}
|
||||
if setCount > 1 {
|
||||
return errors.New("only one connection method allowed per connection (ssh, ssh_cli, tcp, or unix)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||
// The default user "root" and port 22 can be omitted.
|
||||
// Empty user or port components are omitted.
|
||||
type SSHDestination string
|
||||
|
||||
// NewSSHDestination constructs an SSHDestination from user, host, and port components.
|
||||
// If user is empty, it is omitted.
|
||||
// If port is 0, it is omitted.
|
||||
func NewSSHDestination(user, host string, port int) SSHDestination {
|
||||
dst := host
|
||||
if port != 0 && port != DefaultSSHPort {
|
||||
if port != 0 {
|
||||
dst = net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
if user == "" {
|
||||
user = DefaultSSHUser
|
||||
if user != "" {
|
||||
dst = fmt.Sprintf("%s@%s", user, dst)
|
||||
}
|
||||
dst = user + "@" + dst
|
||||
|
||||
return SSHDestination(dst)
|
||||
}
|
||||
|
||||
// Parse parses the SSH destination string into user, host, and port components.
|
||||
// If user is not specified, it returns an empty string.
|
||||
// If port is not specified, it returns 0.
|
||||
func (d SSHDestination) Parse() (user string, host string, port int, err error) {
|
||||
host = string(d)
|
||||
if strings.Contains(host, "@") {
|
||||
user, host, _ = strings.Cut(host, "@")
|
||||
}
|
||||
if user == "" {
|
||||
user = DefaultSSHUser
|
||||
}
|
||||
h, p, sErr := net.SplitHostPort(host)
|
||||
if sErr == nil {
|
||||
host = h
|
||||
port, err = strconv.Atoi(p)
|
||||
}
|
||||
if port == 0 {
|
||||
port = DefaultSSHPort
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMachineConnection_String(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
conn MachineConnection
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ssh connection",
|
||||
conn: MachineConnection{
|
||||
SSH: "user@host.com",
|
||||
},
|
||||
want: "ssh://user@host.com",
|
||||
},
|
||||
{
|
||||
name: "ssh connection with port",
|
||||
conn: MachineConnection{
|
||||
SSH: "user@host.com:2222",
|
||||
},
|
||||
want: "ssh://user@host.com:2222",
|
||||
},
|
||||
{
|
||||
name: "ssh_cli connection",
|
||||
conn: MachineConnection{
|
||||
SSHCLI: "user@host.com",
|
||||
},
|
||||
want: "ssh+cli://user@host.com",
|
||||
},
|
||||
{
|
||||
name: "ssh_cli connection with port",
|
||||
conn: MachineConnection{
|
||||
SSHCLI: "user@host.com:2222",
|
||||
},
|
||||
want: "ssh+cli://user@host.com:2222",
|
||||
},
|
||||
{
|
||||
name: "tcp connection",
|
||||
conn: MachineConnection{
|
||||
TCP: func() *netip.AddrPort {
|
||||
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||
return &addr
|
||||
}(),
|
||||
},
|
||||
want: "tcp://10.0.0.1:8080",
|
||||
},
|
||||
{
|
||||
name: "unix connection",
|
||||
conn: MachineConnection{
|
||||
Unix: "/run/uncloud/uncloud.sock",
|
||||
},
|
||||
want: "unix:///run/uncloud/uncloud.sock",
|
||||
},
|
||||
{
|
||||
name: "no connection",
|
||||
conn: MachineConnection{},
|
||||
want: "unknown connection",
|
||||
},
|
||||
{
|
||||
name: "ssh connection with machine id",
|
||||
conn: MachineConnection{
|
||||
SSH: "user@host.com",
|
||||
MachineID: "ed98b9f7575308c340263cd279e3b568",
|
||||
},
|
||||
// String() does not use MachineID; just verifying entry with it is valid
|
||||
want: "ssh://user@host.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := tt.conn.String()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMachineConnection_Validate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
conn MachineConnection
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "ssh only - valid",
|
||||
conn: MachineConnection{
|
||||
SSH: "user@host",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ssh_cli only - valid",
|
||||
conn: MachineConnection{
|
||||
SSHCLI: "user@host",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "tcp only - valid",
|
||||
conn: MachineConnection{
|
||||
TCP: func() *netip.AddrPort {
|
||||
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||
return &addr
|
||||
}(),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unix only - valid",
|
||||
conn: MachineConnection{
|
||||
Unix: "/path/to/socket",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no connection method - error",
|
||||
conn: MachineConnection{},
|
||||
wantErr: true,
|
||||
errMsg: "no connection method specified",
|
||||
},
|
||||
{
|
||||
name: "ssh and ssh_cli - error",
|
||||
conn: MachineConnection{
|
||||
SSH: "user@host",
|
||||
SSHCLI: "user@host",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "only one connection method allowed",
|
||||
},
|
||||
{
|
||||
name: "ssh and unix - error",
|
||||
conn: MachineConnection{
|
||||
SSH: "user@host",
|
||||
Unix: "/path/to/socket",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "only one connection method allowed",
|
||||
},
|
||||
{
|
||||
name: "ssh and tcp - error",
|
||||
conn: MachineConnection{
|
||||
SSH: "user@host",
|
||||
TCP: func() *netip.AddrPort {
|
||||
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||
return &addr
|
||||
}(),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "only one connection method allowed",
|
||||
},
|
||||
{
|
||||
name: "ssh_cli and tcp - error",
|
||||
conn: MachineConnection{
|
||||
SSHCLI: "user@host",
|
||||
TCP: func() *netip.AddrPort {
|
||||
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||
return &addr
|
||||
}(),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "only one connection method allowed",
|
||||
},
|
||||
{
|
||||
name: "all three - error",
|
||||
conn: MachineConnection{
|
||||
SSH: "user@host",
|
||||
SSHCLI: "user@host2",
|
||||
TCP: func() *netip.AddrPort {
|
||||
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||
return &addr
|
||||
}(),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "only one connection method allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.conn.Validate()
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,21 @@ type Context struct {
|
||||
Name string `yaml:"-"`
|
||||
Connections []MachineConnection `yaml:"connections"`
|
||||
}
|
||||
|
||||
func (c *Context) SetDefaultConnection(index int) {
|
||||
// Do nothing if the index is out of range.
|
||||
if index < 0 || index >= len(c.Connections) {
|
||||
return
|
||||
}
|
||||
|
||||
selected := c.Connections[index]
|
||||
newConnections := make([]MachineConnection, 0, len(c.Connections))
|
||||
newConnections = append(newConnections, selected)
|
||||
|
||||
for i, conn := range c.Connections {
|
||||
if i != index {
|
||||
newConnections = append(newConnections, conn)
|
||||
}
|
||||
}
|
||||
c.Connections = newConnections
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||
)
|
||||
|
||||
// ConnectOptions configures the behavior of cluster connection attempts.
|
||||
type ConnectOptions struct {
|
||||
// Whether to show connection progress spinner if stdout is a terminal or progress logs if not.
|
||||
ShowProgress bool
|
||||
}
|
||||
|
||||
func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts ConnectOptions) (*client.Client, error) {
|
||||
if opts.ShowProgress {
|
||||
return connectClusterWithProgress(ctx, conn)
|
||||
}
|
||||
return connectCluster(ctx, conn)
|
||||
}
|
||||
|
||||
// connectClusterWithProgress connects to the cluster while displaying a progress spinner.
|
||||
// If the stdout is not a terminal, it falls back to simple progress logs to stderr.
|
||||
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||
// If stdout is not a terminal, fall back to simple progress logs.
|
||||
if !IsStdoutTerminal() {
|
||||
fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
|
||||
cli, err := connectCluster(ctx, conn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Connection failed:", err)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Connected to cluster.")
|
||||
}
|
||||
return cli, err
|
||||
}
|
||||
|
||||
// Run the connection TUI model.
|
||||
p := tea.NewProgram(newConnectModel(ctx, conn))
|
||||
model, err := p.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run connection TUI: %w", err)
|
||||
}
|
||||
|
||||
m := model.(connectModel)
|
||||
return m.result.client, m.result.err
|
||||
}
|
||||
|
||||
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||
// Determine which SSH type is configured
|
||||
var sshDest config.SSHDestination
|
||||
var useSSHCLI bool
|
||||
|
||||
// Validate connection configuration early to provide clear error messages.
|
||||
if err := conn.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid connection configuration: %w", err)
|
||||
}
|
||||
|
||||
if conn.SSH != "" {
|
||||
sshDest = conn.SSH
|
||||
useSSHCLI = false
|
||||
} else if conn.SSHCLI != "" {
|
||||
sshDest = conn.SSHCLI
|
||||
useSSHCLI = true
|
||||
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||
} else if conn.Unix != "" {
|
||||
return client.New(ctx, connector.NewUnixConnector(conn.Unix))
|
||||
} else {
|
||||
return nil, errors.New("connection configuration is invalid")
|
||||
}
|
||||
|
||||
// Parse SSH destination and create config (shared for both types)
|
||||
user, host, port, err := sshDest.Parse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse SSH connection %q: %w", sshDest, err)
|
||||
}
|
||||
|
||||
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
||||
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: keyPath,
|
||||
}
|
||||
|
||||
// Create appropriate connector based on type
|
||||
if useSSHCLI {
|
||||
return client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
|
||||
}
|
||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||
}
|
||||
|
||||
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
|
||||
type connectModel struct {
|
||||
ctx context.Context
|
||||
conn config.MachineConnection
|
||||
spinner spinner.Model
|
||||
// showSpinner controls whether the spinner is visible (delayed to avoid flashing).
|
||||
showSpinner bool
|
||||
// done indicates whether the connection attempt has completed (successfully or with error).
|
||||
done bool
|
||||
// result holds the result of the connection attempt.
|
||||
result connectResultMsg
|
||||
}
|
||||
|
||||
type connectResultMsg struct {
|
||||
client *client.Client
|
||||
err error
|
||||
}
|
||||
|
||||
// showSpinnerMsg is sent after a delay to show the spinner.
|
||||
type showSpinnerMsg struct{}
|
||||
|
||||
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.MiniDot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // the same yellow as in compose progress
|
||||
|
||||
return connectModel{
|
||||
ctx: ctx,
|
||||
conn: conn,
|
||||
spinner: s,
|
||||
}
|
||||
}
|
||||
|
||||
func (m connectModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Tick,
|
||||
m.connect(),
|
||||
m.delayShowSpinner(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m connectModel) connect() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
cli, err := connectCluster(m.ctx, m.conn)
|
||||
return connectResultMsg{
|
||||
client: cli,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delayShowSpinner returns a command that sends a message to show the spinner after a delay.
|
||||
// This avoids flashing the spinner if the connection is fast.
|
||||
func (m connectModel) delayShowSpinner() tea.Cmd {
|
||||
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return showSpinnerMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
m.result.err = m.ctx.Err()
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
default:
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case connectResultMsg:
|
||||
m.result = msg
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
|
||||
case showSpinnerMsg:
|
||||
// Only show spinner if connection hasn't completed yet.
|
||||
if !m.done {
|
||||
m.showSpinner = true
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
m.result.err = fmt.Errorf("connection cancelled")
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m connectModel) View() string {
|
||||
// Don't show anything if done or spinner not yet visible.
|
||||
if m.done || !m.showSpinner {
|
||||
return ""
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
|
||||
return fmt.Sprintf("%s %s\n",
|
||||
m.spinner.View(),
|
||||
fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
|
||||
@@ -21,3 +29,31 @@ func ExpandCommaSeparatedValues(values []string) []string {
|
||||
|
||||
return expanded
|
||||
}
|
||||
|
||||
// BindEnvToFlag assigns the value of an environment variable to the given command flag if the flag has not been set.
|
||||
func BindEnvToFlag(cmd *cobra.Command, flagName, envVar string) {
|
||||
if value := os.Getenv(envVar); value != "" && !cmd.Flags().Changed(flagName) {
|
||||
if err := cmd.Flags().Set(flagName, value); err != nil {
|
||||
log.Fatalf("Failed to bind environment variable '%s' to flag '%s': %v", envVar, flagName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseWireGuardEndpoints parses a list of endpoint strings into a list of IPPort protobuf messages. Each value can
|
||||
// be an IP address, IP:PORT, IPv6, or [IPv6]:PORT. If the port is omitted, the default WireGuard port is used.
|
||||
func ParseWireGuardEndpoints(values []string) ([]*pb.IPPort, error) {
|
||||
endpoints := make([]*pb.IPPort, 0, len(values))
|
||||
for _, v := range values {
|
||||
ap, err := netip.ParseAddrPort(v)
|
||||
if err != nil {
|
||||
// Try parsing as a bare IP address and use the default WireGuard port.
|
||||
addr, addrErr := netip.ParseAddr(v)
|
||||
if addrErr != nil {
|
||||
return nil, fmt.Errorf("invalid endpoint '%s': must be IP, IPv6, IP:PORT, or [IPv6]:PORT", v)
|
||||
}
|
||||
ap = netip.AddrPortFrom(addr, network.WireGuardPort)
|
||||
}
|
||||
endpoints = append(endpoints, pb.NewIPPort(ap))
|
||||
}
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
+14
-1
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -25,6 +26,7 @@ type RemoteMachine struct {
|
||||
Host string
|
||||
Port int
|
||||
KeyPath string
|
||||
UseSSHCLI bool // indicates ssh+cli:// should be used
|
||||
}
|
||||
|
||||
func installCmd(user string, version string) string {
|
||||
@@ -85,7 +87,13 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptResetMachine(ctx context.Context, machineClient pb.MachineClient) error {
|
||||
func promptResetMachine() error {
|
||||
if !IsStdinTerminal() {
|
||||
return errors.New("the remote machine is already initialised as a cluster member; " +
|
||||
"cannot ask to confirm reset in non-interactive mode, " +
|
||||
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
|
||||
}
|
||||
|
||||
var confirm bool
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
@@ -109,10 +117,15 @@ func promptResetMachine(ctx context.Context, machineClient pb.MachineClient) err
|
||||
return fmt.Errorf("remote machine is already initialised as a cluster member")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetAndWaitMachine(ctx context.Context, machineClient pb.MachineClient) error {
|
||||
if _, err := machineClient.Reset(ctx, &pb.ResetRequest{}); err != nil {
|
||||
return fmt.Errorf("reset remote machine: %w. You can also manually run 'uncloud-uninstall' "+
|
||||
"on the remote machine to fully uninstall Uncloud from it", err)
|
||||
}
|
||||
|
||||
fmt.Println("Resetting the remote machine...")
|
||||
if err := waitMachineReady(ctx, machineClient, 1*time.Minute); err != nil {
|
||||
return fmt.Errorf("wait for machine to be ready after reset: %w", err)
|
||||
|
||||
+16
-1
@@ -1,6 +1,11 @@
|
||||
package cli
|
||||
|
||||
import "github.com/charmbracelet/huh"
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func Confirm() (bool, error) {
|
||||
var confirmed bool
|
||||
@@ -21,3 +26,13 @@ func Confirm() (bool, error) {
|
||||
|
||||
return confirmed, nil
|
||||
}
|
||||
|
||||
// IsStdinTerminal checks if the standard input is a terminal (TTY).
|
||||
func IsStdinTerminal() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
|
||||
// IsStdoutTerminal checks if the standard output is a terminal (TTY).
|
||||
func IsStdoutTerminal() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
)
|
||||
|
||||
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
|
||||
// TODO: ideally, this should be an RPC call to the daemon API to ensure the config is created and up-to-date.
|
||||
func MachineToken(dataDir string) (machine.Token, error) {
|
||||
state, err := machine.ParseState(machine.StatePath(dataDir))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return machine.Token{}, fmt.Errorf("load machine config (is uncloudd daemon running?): %w", err)
|
||||
}
|
||||
return machine.Token{}, fmt.Errorf("load machine config: %w", err)
|
||||
}
|
||||
if len(state.Network.PublicKey) == 0 {
|
||||
return machine.Token{}, errors.New("public key is not set in machine config")
|
||||
}
|
||||
|
||||
ips, err := network.ListRoutableIPs()
|
||||
if err != nil {
|
||||
return machine.Token{}, fmt.Errorf("list routable addresses: %w", err)
|
||||
}
|
||||
publicIP, err := network.GetPublicIP()
|
||||
// Ignore the error if failed to get the public IP using API services.
|
||||
if err == nil {
|
||||
ips = append(ips, publicIP)
|
||||
}
|
||||
|
||||
endpoints := make([]netip.AddrPort, len(ips))
|
||||
for i, ip := range ips {
|
||||
endpoints[i] = netip.AddrPortFrom(ip, network.WireGuardPort)
|
||||
}
|
||||
return machine.NewToken(state.Network.PublicKey, publicIP, endpoints), nil
|
||||
}
|
||||
@@ -11,7 +11,12 @@ import (
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
*client.Client
|
||||
}
|
||||
|
||||
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
||||
// TODO(lhf): convert into a Client method.
|
||||
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
|
||||
// Retry to ping the Docker daemon until it's ready or the context is canceled.
|
||||
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
// CreateContainerWithImagePull creates a container. If the image is missing, it pulls the image first.
|
||||
func (cli *Client) CreateContainerWithImagePull(
|
||||
ctx context.Context, name string, config *container.Config, hostConfig *container.HostConfig,
|
||||
) (container.CreateResponse, error) {
|
||||
resp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name)
|
||||
if err == nil || !errdefs.IsNotFound(err) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
pullCh, err := cli.PullImage(ctx, config.Image, image.PullOptions{})
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("pull image: %w", err)
|
||||
}
|
||||
|
||||
// Drain the pull channel until it's closed (image is fully pulled) or an error occurs.
|
||||
// If the context is canceled during the pull, the channel will receive a context cancellation error.
|
||||
for msg := range pullCh {
|
||||
if msg.Err != nil {
|
||||
return resp, fmt.Errorf("pull image: %w", msg.Err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create container again after image pull.
|
||||
if resp, err = cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name); err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// WaitPortPublished waits for a container port to be published on the host which happens asynchronously.
|
||||
func (cli *Client) WaitPortPublished(ctx context.Context, containerID string, port nat.Port) ([]nat.PortBinding, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
c, err := cli.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect container: %w", err)
|
||||
}
|
||||
|
||||
binding, ok := c.NetworkSettings.Ports[port]
|
||||
if ok && len(binding) > 0 {
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return nil, errors.New("timeout")
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
dockercommand "github.com/docker/cli/cli/command"
|
||||
dockerconfig "github.com/docker/cli/cli/config"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
)
|
||||
|
||||
type PullPushImageMessage struct {
|
||||
Message jsonmessage.JSONMessage
|
||||
Err error
|
||||
}
|
||||
|
||||
// PullImage pulls an image and returns a channel to receive progress messages.
|
||||
func (cli *Client) PullImage(
|
||||
ctx context.Context, image string, opts image.PullOptions,
|
||||
) (<-chan PullPushImageMessage, error) {
|
||||
if opts.RegistryAuth == "" {
|
||||
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||
if encodedAuth, err := RetrieveLocalDockerRegistryAuth(image); err == nil {
|
||||
opts.RegistryAuth = encodedAuth
|
||||
}
|
||||
}
|
||||
|
||||
respBody, err := cli.ImagePull(ctx, image, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan PullPushImageMessage)
|
||||
go processPullPushImageResp(ctx, respBody, ch)
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// PushImage pushes an image and returns a channel to receive progress messages.
|
||||
func (cli *Client) PushImage(
|
||||
ctx context.Context, image string, opts image.PushOptions,
|
||||
) (<-chan PullPushImageMessage, error) {
|
||||
if opts.RegistryAuth == "" {
|
||||
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||
encodedAuth, _ := RetrieveLocalDockerRegistryAuth(image)
|
||||
if encodedAuth == "" {
|
||||
// If no credentials are found, provide an encoded empty auth config to work around the bug in Docker:
|
||||
// https://github.com/moby/moby/issues/50729
|
||||
var err error
|
||||
if encodedAuth, err = registry.EncodeAuthConfig(registry.AuthConfig{}); err != nil {
|
||||
return nil, fmt.Errorf("encode empty auth config: %w", err)
|
||||
}
|
||||
}
|
||||
opts.RegistryAuth = encodedAuth
|
||||
}
|
||||
|
||||
respBody, err := cli.ImagePush(ctx, image, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan PullPushImageMessage)
|
||||
go processPullPushImageResp(ctx, respBody, ch)
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// processPullPushImageResp decodes JSON messages from the image pull/push response body and
|
||||
// sends them to the provided channel.
|
||||
func processPullPushImageResp(ctx context.Context, respBody io.ReadCloser, ch chan<- PullPushImageMessage) {
|
||||
defer respBody.Close()
|
||||
defer close(ch)
|
||||
|
||||
decoder := json.NewDecoder(respBody)
|
||||
for {
|
||||
var jm jsonmessage.JSONMessage
|
||||
if err := decoder.Decode(&jm); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return
|
||||
}
|
||||
ch <- PullPushImageMessage{Err: fmt.Errorf("decode image pull/push message: %w", err)}
|
||||
return
|
||||
}
|
||||
|
||||
msg := PullPushImageMessage{Message: jm}
|
||||
if jm.Error != nil {
|
||||
msg.Err = errors.New(jm.Error.Message)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ch <- PullPushImageMessage{Err: ctx.Err()}
|
||||
return
|
||||
default:
|
||||
ch <- msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RetrieveLocalDockerRegistryAuth retrieves the authentication token for the specified image from the local Docker
|
||||
// config file. It returns the encoded authentication token if it contains any credentials, or an empty string if
|
||||
// no credentials are found.
|
||||
func RetrieveLocalDockerRegistryAuth(image string) (string, error) {
|
||||
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||
dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr)
|
||||
encodedAuth, err := dockercommand.RetrieveAuthTokenFromImage(dockerConfig, image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The encodedAuth can be a base64-encoded "{}" (empty JSON object) or include a server address but no credentials.
|
||||
// Return encodedAuth only if it contains any credentials.
|
||||
auth, err := registry.DecodeAuthConfig(encodedAuth)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode auth config: %w", err)
|
||||
}
|
||||
|
||||
if auth.Username == "" &&
|
||||
auth.Password == "" &&
|
||||
auth.Auth == "" &&
|
||||
auth.IdentityToken == "" &&
|
||||
auth.RegistryToken == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return encodedAuth, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package gitutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GitState contains information about the Git repository state.
|
||||
type GitState struct {
|
||||
// Date is the current commit datetime.
|
||||
Date time.Time
|
||||
// IsDirty indicates whether there are uncommitted changes.
|
||||
IsDirty bool
|
||||
// IsRepo indicates whether the directory is a Git repository.
|
||||
IsRepo bool
|
||||
// SHA is the full SHA-1 (40 characters) of the current commit.
|
||||
SHA string
|
||||
}
|
||||
|
||||
// InspectGitState inspects the Git repo state from the specified directory.
|
||||
// If the directory is not a Git repository, or if git utility is not available,
|
||||
// returns GitState with IsRepo=false and no error.
|
||||
func InspectGitState(dir string) (GitState, error) {
|
||||
// Check if git command is available.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return GitState{}, nil
|
||||
}
|
||||
|
||||
state := GitState{
|
||||
IsRepo: isGitRepo(dir),
|
||||
}
|
||||
if !state.IsRepo {
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// Get the current commit SHA.
|
||||
sha, err := gitCommand(dir, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("get current commit SHA: %w", err)
|
||||
}
|
||||
state.SHA = strings.TrimSpace(sha)
|
||||
|
||||
// Get the current commit timestamp.
|
||||
timestamp, err := gitCommand(dir, "log", "-1", "--format=%ct")
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("get current commit timestamp: %w", err)
|
||||
}
|
||||
seconds, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64)
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("parse current commit timestamp: %w", err)
|
||||
}
|
||||
state.Date = time.Unix(seconds, 0).UTC()
|
||||
|
||||
// Check for uncommitted changes.
|
||||
status, err := gitCommand(dir, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("check git status: %w", err)
|
||||
}
|
||||
state.IsDirty = strings.TrimSpace(status) != ""
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// isGitRepo checks if the directory is a Git repository.
|
||||
func isGitRepo(dir string) bool {
|
||||
_, err := gitCommand(dir, "rev-parse", "--git-dir")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// gitCommand runs a git command in the specified directory.
|
||||
func gitCommand(dir string, args ...string) (string, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("git command failed: %s", exitErr.Stderr)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// ShortSHA returns a truncated SHA with the specified length.
|
||||
// If length is negative, zero, or greater than the SHA length, it returns the full SHA.
|
||||
// Returns empty string if SHA is empty.
|
||||
func (s *GitState) ShortSHA(length int) string {
|
||||
if length <= 0 || length > len(s.SHA) {
|
||||
length = len(s.SHA)
|
||||
}
|
||||
return s.SHA[:length]
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package gitutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInspectGitState_GitNotAvailable(t *testing.T) {
|
||||
// Reset the PATH env var to simulate git not being available.
|
||||
t.Setenv("PATH", "")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
state, err := InspectGitState(tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, state.IsRepo)
|
||||
assert.Empty(t, state.SHA)
|
||||
assert.Empty(t, state.ShortSHA(7))
|
||||
assert.True(t, state.Date.IsZero())
|
||||
assert.False(t, state.IsDirty)
|
||||
}
|
||||
|
||||
func TestInspectGitState_NotARepo(t *testing.T) {
|
||||
// Create a temporary directory that's not a git repo.
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
state, err := InspectGitState(tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// If tmpDir happens to be in a git repo (e.g., parent directory), skip this test.
|
||||
// This is common when running tests from within the project itself.
|
||||
if state.IsRepo {
|
||||
t.Skip("tmpDir is within a git repository, skipping test")
|
||||
}
|
||||
|
||||
assert.False(t, state.IsRepo)
|
||||
assert.Empty(t, state.SHA)
|
||||
assert.Empty(t, state.ShortSHA(7))
|
||||
assert.True(t, state.Date.IsZero()) // Should have zero time if not a repo.
|
||||
assert.False(t, state.IsDirty)
|
||||
}
|
||||
|
||||
func TestInspectGitState_CleanRepo(t *testing.T) {
|
||||
// Create a temporary git repo.
|
||||
tmpDir := t.TempDir()
|
||||
initGitRepo(t, tmpDir)
|
||||
|
||||
createAndCommitFile(t, tmpDir, "test.txt", "test content")
|
||||
|
||||
state, err := InspectGitState(tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, state.IsRepo)
|
||||
assert.Len(t, state.SHA, 40)
|
||||
assert.Equal(t, state.ShortSHA(7), state.SHA[:7])
|
||||
assert.False(t, state.Date.IsZero())
|
||||
assert.False(t, state.IsDirty)
|
||||
}
|
||||
|
||||
func TestInspectGitState_DirtyRepo(t *testing.T) {
|
||||
// Create a temporary git repo.
|
||||
tmpDir := t.TempDir()
|
||||
initGitRepo(t, tmpDir)
|
||||
|
||||
createAndCommitFile(t, tmpDir, "test.txt", "test content")
|
||||
|
||||
// Modify the file to make the repo dirty.
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("modified content"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
state, err := InspectGitState(tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, state.IsRepo)
|
||||
assert.True(t, state.IsDirty)
|
||||
}
|
||||
|
||||
func TestInspectGitState_UntrackedFiles(t *testing.T) {
|
||||
// Create a temporary git repo.
|
||||
tmpDir := t.TempDir()
|
||||
initGitRepo(t, tmpDir)
|
||||
|
||||
createAndCommitFile(t, tmpDir, "test.txt", "test content")
|
||||
|
||||
// Add an untracked file.
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "untracked.txt"), []byte("untracked"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
state, err := InspectGitState(tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, state.IsRepo)
|
||||
assert.True(t, state.IsDirty) // Untracked files make the repo dirty.
|
||||
}
|
||||
|
||||
func TestGitState_ShortSHA(t *testing.T) {
|
||||
state := &GitState{
|
||||
SHA: "1234567890abcdef1234567890abcdef12345678",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
length int
|
||||
expected string
|
||||
}{
|
||||
{"short 7", 7, "1234567"},
|
||||
{"short 10", 10, "1234567890"},
|
||||
{"full SHA", 40, "1234567890abcdef1234567890abcdef12345678"},
|
||||
{"longer than SHA", 50, "1234567890abcdef1234567890abcdef12345678"},
|
||||
{"negative", -42, "1234567890abcdef1234567890abcdef12345678"},
|
||||
{"zero", 0, "1234567890abcdef1234567890abcdef12345678"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := state.ShortSHA(tt.length)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitState_ShortSHA_Empty(t *testing.T) {
|
||||
state := &GitState{
|
||||
SHA: "",
|
||||
}
|
||||
|
||||
result := state.ShortSHA(7)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
// Helper functions.
|
||||
|
||||
func initGitRepo(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
// Initialize git repo.
|
||||
runGitCommand(t, dir, "init")
|
||||
runGitCommand(t, dir, "config", "user.email", "test@example.com")
|
||||
runGitCommand(t, dir, "config", "user.name", "Test User")
|
||||
}
|
||||
|
||||
func createAndCommitFile(t *testing.T, dir, filename, content string) {
|
||||
t.Helper()
|
||||
|
||||
// Create file.
|
||||
path := filepath.Join(dir, filename)
|
||||
err := os.WriteFile(path, []byte(content), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Commit file.
|
||||
runGitCommand(t, dir, "add", filename)
|
||||
runGitCommand(t, dir, "commit", "-m", "Add "+filename)
|
||||
}
|
||||
|
||||
func runGitCommand(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "git command failed: %s", output)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func InitLoggerFromEnv() {
|
||||
debugValues := []string{"1", "true", "yes"}
|
||||
if slices.Contains(debugValues, strings.ToLower(os.Getenv("DEBUG"))) {
|
||||
logger := slog.New(NewSlogTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
}
|
||||
slog.Debug("logger initialized")
|
||||
}
|
||||
+1267
-361
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ package api;
|
||||
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "internal/machine/api/pb/common.proto";
|
||||
|
||||
service Docker {
|
||||
@@ -15,11 +16,15 @@ service Docker {
|
||||
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
|
||||
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||
|
||||
rpc ExecContainer(stream ExecContainerRequest) returns (stream ExecContainerResponse);
|
||||
rpc ContainerLogs(ContainerLogsRequest) returns (stream ContainerLogEntry);
|
||||
|
||||
rpc PullImage(PullImageRequest) returns (stream JSONMessage);
|
||||
rpc InspectImage(InspectImageRequest) returns (InspectImageResponse);
|
||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||
// Docker auth credentials if necessary.
|
||||
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
||||
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
|
||||
|
||||
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
|
||||
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
|
||||
@@ -91,6 +96,64 @@ message RemoveContainerRequest {
|
||||
bytes options = 2;
|
||||
}
|
||||
|
||||
message ExecContainerRequest {
|
||||
oneof payload {
|
||||
// Initial configuration for the exec session. Must be sent as the first message.
|
||||
ExecConfig config = 1;
|
||||
// Raw stdin data to be written to the exec process.
|
||||
bytes stdin = 2;
|
||||
// TTY resize event (only used when TTY is enabled).
|
||||
ResizeEvent resize = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ExecConfig {
|
||||
// Container ID to execute the command in.
|
||||
string container_id = 1;
|
||||
// JSON serialised ExecOptions
|
||||
bytes options = 2;
|
||||
}
|
||||
|
||||
message ResizeEvent {
|
||||
uint32 height = 1;
|
||||
uint32 width = 2;
|
||||
}
|
||||
|
||||
message ExecContainerResponse {
|
||||
oneof payload {
|
||||
// Exec instance ID returned after creating the exec.
|
||||
string exec_id = 1;
|
||||
// Raw stdout data from the exec process.
|
||||
bytes stdout = 2;
|
||||
// Raw stderr data from the exec process (only when TTY is disabled).
|
||||
bytes stderr = 3;
|
||||
// Exit code of the exec process. Sent as the final message.
|
||||
int32 exit_code = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message ContainerLogsRequest {
|
||||
string container_id = 1;
|
||||
// Options for logs retrieval.
|
||||
bool follow = 2;
|
||||
int32 tail = 3; // -1 means all
|
||||
string since = 4; // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
|
||||
string until = 5; // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
|
||||
}
|
||||
|
||||
message ContainerLogEntry {
|
||||
enum StreamType {
|
||||
UNKNOWN = 0;
|
||||
STDOUT = 1;
|
||||
STDERR = 2;
|
||||
HEARTBEAT = 3;
|
||||
}
|
||||
StreamType stream = 1;
|
||||
google.protobuf.Timestamp timestamp = 2;
|
||||
// Log line content. Empty for heartbeat entries.
|
||||
bytes message = 3;
|
||||
}
|
||||
|
||||
message PullImageRequest {
|
||||
string image = 1;
|
||||
// JSON serialised image.PullOptions.
|
||||
@@ -113,7 +176,7 @@ message InspectImageResponse {
|
||||
|
||||
message Image {
|
||||
Metadata metadata = 1;
|
||||
// JSON serialised types.ImageInspect.
|
||||
// JSON serialised image.InspectResponse.
|
||||
bytes image = 2;
|
||||
}
|
||||
|
||||
@@ -134,6 +197,24 @@ message RemoteImage {
|
||||
bytes manifest = 3;
|
||||
}
|
||||
|
||||
message ListImagesRequest {
|
||||
// JSON serialised image.ListOptions.
|
||||
bytes options = 1;
|
||||
}
|
||||
|
||||
message ListImagesResponse {
|
||||
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
|
||||
repeated MachineImages messages = 1;
|
||||
}
|
||||
|
||||
message MachineImages {
|
||||
Metadata metadata = 1;
|
||||
// JSON serialised []image.Summary.
|
||||
bytes images = 2;
|
||||
// True if Docker uses the containerd image store, false if it uses its internal image store.
|
||||
bool containerd_store = 3;
|
||||
}
|
||||
|
||||
message CreateVolumeRequest {
|
||||
// JSON serialised volume.CreateOptions.
|
||||
bytes options = 1;
|
||||
|
||||
@@ -26,9 +26,12 @@ const (
|
||||
Docker_StopContainer_FullMethodName = "/api.Docker/StopContainer"
|
||||
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
|
||||
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
|
||||
Docker_ExecContainer_FullMethodName = "/api.Docker/ExecContainer"
|
||||
Docker_ContainerLogs_FullMethodName = "/api.Docker/ContainerLogs"
|
||||
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
||||
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
||||
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
||||
Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
|
||||
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
|
||||
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
|
||||
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
|
||||
@@ -48,11 +51,14 @@ type DockerClient interface {
|
||||
StopContainer(ctx context.Context, in *StopContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error)
|
||||
RemoveContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error)
|
||||
ContainerLogs(ctx context.Context, in *ContainerLogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ContainerLogEntry], error)
|
||||
PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error)
|
||||
InspectImage(ctx context.Context, in *InspectImageRequest, opts ...grpc.CallOption) (*InspectImageResponse, error)
|
||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||
// Docker auth credentials if necessary.
|
||||
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
|
||||
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error)
|
||||
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
|
||||
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
|
||||
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
@@ -130,9 +136,41 @@ func (c *dockerClient) RemoveContainer(ctx context.Context, in *RemoveContainerR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[0], Docker_ExecContainer_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[ExecContainerRequest, ExecContainerResponse]{ClientStream: stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Docker_ExecContainerClient = grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse]
|
||||
|
||||
func (c *dockerClient) ContainerLogs(ctx context.Context, in *ContainerLogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ContainerLogEntry], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[1], Docker_ContainerLogs_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[ContainerLogsRequest, ContainerLogEntry]{ClientStream: stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := x.ClientStream.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Docker_ContainerLogsClient = grpc.ServerStreamingClient[ContainerLogEntry]
|
||||
|
||||
func (c *dockerClient) PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[0], Docker_PullImage_FullMethodName, cOpts...)
|
||||
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[2], Docker_PullImage_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -169,6 +207,16 @@ func (c *dockerClient) InspectRemoteImage(ctx context.Context, in *InspectRemote
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListImagesResponse)
|
||||
err := c.cc.Invoke(ctx, Docker_ListImages_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateVolumeResponse)
|
||||
@@ -249,11 +297,14 @@ type DockerServer interface {
|
||||
StopContainer(context.Context, *StopContainerRequest) (*emptypb.Empty, error)
|
||||
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
|
||||
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
||||
ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error
|
||||
ContainerLogs(*ContainerLogsRequest, grpc.ServerStreamingServer[ContainerLogEntry]) error
|
||||
PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error
|
||||
InspectImage(context.Context, *InspectImageRequest) (*InspectImageResponse, error)
|
||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||
// Docker auth credentials if necessary.
|
||||
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
||||
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
|
||||
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
|
||||
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
|
||||
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
|
||||
@@ -289,6 +340,12 @@ func (UnimplementedDockerServer) ListContainers(context.Context, *ListContainers
|
||||
func (UnimplementedDockerServer) RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RemoveContainer not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method ExecContainer not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) ContainerLogs(*ContainerLogsRequest, grpc.ServerStreamingServer[ContainerLogEntry]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method ContainerLogs not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method PullImage not implemented")
|
||||
}
|
||||
@@ -298,6 +355,9 @@ func (UnimplementedDockerServer) InspectImage(context.Context, *InspectImageRequ
|
||||
func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method InspectRemoteImage not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
|
||||
}
|
||||
@@ -448,6 +508,24 @@ func _Docker_RemoveContainer_Handler(srv interface{}, ctx context.Context, dec f
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Docker_ExecContainer_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(DockerServer).ExecContainer(&grpc.GenericServerStream[ExecContainerRequest, ExecContainerResponse]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Docker_ExecContainerServer = grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]
|
||||
|
||||
func _Docker_ContainerLogs_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(ContainerLogsRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(DockerServer).ContainerLogs(m, &grpc.GenericServerStream[ContainerLogsRequest, ContainerLogEntry]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Docker_ContainerLogsServer = grpc.ServerStreamingServer[ContainerLogEntry]
|
||||
|
||||
func _Docker_PullImage_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(PullImageRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
@@ -495,6 +573,24 @@ func _Docker_InspectRemoteImage_Handler(srv interface{}, ctx context.Context, de
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Docker_ListImages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListImagesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DockerServer).ListImages(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Docker_ListImages_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DockerServer).ListImages(ctx, req.(*ListImagesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateVolumeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -660,6 +756,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "InspectRemoteImage",
|
||||
Handler: _Docker_InspectRemoteImage_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListImages",
|
||||
Handler: _Docker_ListImages_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateVolume",
|
||||
Handler: _Docker_CreateVolume_Handler,
|
||||
@@ -690,6 +790,17 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "ExecContainer",
|
||||
Handler: _Docker_ExecContainer_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "ContainerLogs",
|
||||
Handler: _Docker_ContainerLogs_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "PullImage",
|
||||
Handler: _Docker_PullImage_Handler,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
@@ -232,6 +233,8 @@ type InitClusterRequest struct {
|
||||
// *InitClusterRequest_PublicIp
|
||||
// *InitClusterRequest_PublicIpAuto
|
||||
PublicIpConfig isInitClusterRequest_PublicIpConfig `protobuf_oneof:"public_ip_config"`
|
||||
// Optional WireGuard endpoints other machines will use to connect to this machine instead of auto-discovered ones.
|
||||
WireguardEndpoints []*IPPort `protobuf:"bytes,5,rep,name=wireguard_endpoints,json=wireguardEndpoints,proto3" json:"wireguard_endpoints,omitempty"`
|
||||
}
|
||||
|
||||
func (x *InitClusterRequest) Reset() {
|
||||
@@ -301,6 +304,13 @@ func (x *InitClusterRequest) GetPublicIpAuto() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *InitClusterRequest) GetWireguardEndpoints() []*IPPort {
|
||||
if x != nil {
|
||||
return x.WireguardEndpoints
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isInitClusterRequest_PublicIpConfig interface {
|
||||
isInitClusterRequest_PublicIpConfig()
|
||||
}
|
||||
@@ -371,6 +381,8 @@ type JoinClusterRequest struct {
|
||||
|
||||
Machine *MachineInfo `protobuf:"bytes,1,opt,name=machine,proto3" json:"machine,omitempty"`
|
||||
OtherMachines []*MachineInfo `protobuf:"bytes,3,rep,name=other_machines,json=otherMachines,proto3" json:"other_machines,omitempty"`
|
||||
// Minimum store database version the new machine should sync to before starting cluster operations.
|
||||
MinStoreDbVersion int64 `protobuf:"varint,4,opt,name=min_store_db_version,json=minStoreDbVersion,proto3" json:"min_store_db_version,omitempty"`
|
||||
}
|
||||
|
||||
func (x *JoinClusterRequest) Reset() {
|
||||
@@ -419,6 +431,125 @@ func (x *JoinClusterRequest) GetOtherMachines() []*MachineInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *JoinClusterRequest) GetMinStoreDbVersion() int64 {
|
||||
if x != nil {
|
||||
return x.MinStoreDbVersion
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type InspectMachineResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
// Must contain only one repeated messages field to allow broadcasting InspectMachine requests to multiple machines.
|
||||
Machines []*MachineDetails `protobuf:"bytes,1,rep,name=machines,proto3" json:"machines,omitempty"`
|
||||
}
|
||||
|
||||
func (x *InspectMachineResponse) Reset() {
|
||||
*x = InspectMachineResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *InspectMachineResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*InspectMachineResponse) ProtoMessage() {}
|
||||
|
||||
func (x *InspectMachineResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use InspectMachineResponse.ProtoReflect.Descriptor instead.
|
||||
func (*InspectMachineResponse) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *InspectMachineResponse) GetMachines() []*MachineDetails {
|
||||
if x != nil {
|
||||
return x.Machines
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type MachineDetails struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Metadata *Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"`
|
||||
Machine *MachineInfo `protobuf:"bytes,2,opt,name=machine,proto3" json:"machine,omitempty"`
|
||||
// Current Corrosion cr-sqlite database version (Lamport timestamp) of the cluster store.
|
||||
StoreDbVersion int64 `protobuf:"varint,3,opt,name=store_db_version,json=storeDbVersion,proto3" json:"store_db_version,omitempty"`
|
||||
}
|
||||
|
||||
func (x *MachineDetails) Reset() {
|
||||
*x = MachineDetails{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *MachineDetails) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*MachineDetails) ProtoMessage() {}
|
||||
|
||||
func (x *MachineDetails) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use MachineDetails.ProtoReflect.Descriptor instead.
|
||||
func (*MachineDetails) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *MachineDetails) GetMetadata() *Metadata {
|
||||
if x != nil {
|
||||
return x.Metadata
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *MachineDetails) GetMachine() *MachineInfo {
|
||||
if x != nil {
|
||||
return x.Machine
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *MachineDetails) GetStoreDbVersion() int64 {
|
||||
if x != nil {
|
||||
return x.StoreDbVersion
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -430,7 +561,7 @@ type TokenResponse struct {
|
||||
func (x *TokenResponse) Reset() {
|
||||
*x = TokenResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -443,7 +574,7 @@ func (x *TokenResponse) String() string {
|
||||
func (*TokenResponse) ProtoMessage() {}
|
||||
|
||||
func (x *TokenResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -456,7 +587,7 @@ func (x *TokenResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use TokenResponse.ProtoReflect.Descriptor instead.
|
||||
func (*TokenResponse) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{6}
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *TokenResponse) GetToken() string {
|
||||
@@ -475,7 +606,7 @@ type ResetRequest struct {
|
||||
func (x *ResetRequest) Reset() {
|
||||
*x = ResetRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -488,7 +619,7 @@ func (x *ResetRequest) String() string {
|
||||
func (*ResetRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ResetRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -501,7 +632,7 @@ func (x *ResetRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ResetRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ResetRequest) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{7}
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -518,7 +649,7 @@ type Service struct {
|
||||
func (x *Service) Reset() {
|
||||
*x = Service{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -531,7 +662,7 @@ func (x *Service) String() string {
|
||||
func (*Service) ProtoMessage() {}
|
||||
|
||||
func (x *Service) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[10]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -544,7 +675,7 @@ func (x *Service) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Service.ProtoReflect.Descriptor instead.
|
||||
func (*Service) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{8}
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *Service) GetId() string {
|
||||
@@ -586,7 +717,7 @@ type InspectServiceRequest struct {
|
||||
func (x *InspectServiceRequest) Reset() {
|
||||
*x = InspectServiceRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -599,7 +730,7 @@ func (x *InspectServiceRequest) String() string {
|
||||
func (*InspectServiceRequest) ProtoMessage() {}
|
||||
|
||||
func (x *InspectServiceRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[11]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -612,7 +743,7 @@ func (x *InspectServiceRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use InspectServiceRequest.ProtoReflect.Descriptor instead.
|
||||
func (*InspectServiceRequest) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{9}
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *InspectServiceRequest) GetId() string {
|
||||
@@ -633,7 +764,7 @@ type InspectServiceResponse struct {
|
||||
func (x *InspectServiceResponse) Reset() {
|
||||
*x = InspectServiceResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[10]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -646,7 +777,7 @@ func (x *InspectServiceResponse) String() string {
|
||||
func (*InspectServiceResponse) ProtoMessage() {}
|
||||
|
||||
func (x *InspectServiceResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[10]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[12]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -659,7 +790,7 @@ func (x *InspectServiceResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use InspectServiceResponse.ProtoReflect.Descriptor instead.
|
||||
func (*InspectServiceResponse) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{10}
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *InspectServiceResponse) GetService() *Service {
|
||||
@@ -669,6 +800,164 @@ func (x *InspectServiceResponse) GetService() *Service {
|
||||
return nil
|
||||
}
|
||||
|
||||
type InspectWireGuardNetworkResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
InterfaceName string `protobuf:"bytes,1,opt,name=interface_name,json=interfaceName,proto3" json:"interface_name,omitempty"`
|
||||
PublicKey []byte `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"`
|
||||
ListenPort int32 `protobuf:"varint,3,opt,name=listen_port,json=listenPort,proto3" json:"listen_port,omitempty"`
|
||||
Peers []*WireGuardPeer `protobuf:"bytes,4,rep,name=peers,proto3" json:"peers,omitempty"`
|
||||
}
|
||||
|
||||
func (x *InspectWireGuardNetworkResponse) Reset() {
|
||||
*x = InspectWireGuardNetworkResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *InspectWireGuardNetworkResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*InspectWireGuardNetworkResponse) ProtoMessage() {}
|
||||
|
||||
func (x *InspectWireGuardNetworkResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[13]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use InspectWireGuardNetworkResponse.ProtoReflect.Descriptor instead.
|
||||
func (*InspectWireGuardNetworkResponse) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *InspectWireGuardNetworkResponse) GetInterfaceName() string {
|
||||
if x != nil {
|
||||
return x.InterfaceName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InspectWireGuardNetworkResponse) GetPublicKey() []byte {
|
||||
if x != nil {
|
||||
return x.PublicKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *InspectWireGuardNetworkResponse) GetListenPort() int32 {
|
||||
if x != nil {
|
||||
return x.ListenPort
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *InspectWireGuardNetworkResponse) GetPeers() []*WireGuardPeer {
|
||||
if x != nil {
|
||||
return x.Peers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WireGuardPeer struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
PublicKey []byte `protobuf:"bytes,1,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"`
|
||||
Endpoint string `protobuf:"bytes,2,opt,name=endpoint,proto3" json:"endpoint,omitempty"`
|
||||
LastHandshakeTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=last_handshake_time,json=lastHandshakeTime,proto3" json:"last_handshake_time,omitempty"`
|
||||
ReceiveBytes int64 `protobuf:"varint,4,opt,name=receive_bytes,json=receiveBytes,proto3" json:"receive_bytes,omitempty"`
|
||||
TransmitBytes int64 `protobuf:"varint,5,opt,name=transmit_bytes,json=transmitBytes,proto3" json:"transmit_bytes,omitempty"`
|
||||
AllowedIps []string `protobuf:"bytes,6,rep,name=allowed_ips,json=allowedIps,proto3" json:"allowed_ips,omitempty"`
|
||||
}
|
||||
|
||||
func (x *WireGuardPeer) Reset() {
|
||||
*x = WireGuardPeer{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *WireGuardPeer) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*WireGuardPeer) ProtoMessage() {}
|
||||
|
||||
func (x *WireGuardPeer) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[14]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use WireGuardPeer.ProtoReflect.Descriptor instead.
|
||||
func (*WireGuardPeer) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *WireGuardPeer) GetPublicKey() []byte {
|
||||
if x != nil {
|
||||
return x.PublicKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *WireGuardPeer) GetEndpoint() string {
|
||||
if x != nil {
|
||||
return x.Endpoint
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *WireGuardPeer) GetLastHandshakeTime() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.LastHandshakeTime
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *WireGuardPeer) GetReceiveBytes() int64 {
|
||||
if x != nil {
|
||||
return x.ReceiveBytes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *WireGuardPeer) GetTransmitBytes() int64 {
|
||||
if x != nil {
|
||||
return x.TransmitBytes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *WireGuardPeer) GetAllowedIps() []string {
|
||||
if x != nil {
|
||||
return x.AllowedIps
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Service_Container struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -682,7 +971,7 @@ type Service_Container struct {
|
||||
func (x *Service_Container) Reset() {
|
||||
*x = Service_Container{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[11]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -695,7 +984,7 @@ func (x *Service_Container) String() string {
|
||||
func (*Service_Container) ProtoMessage() {}
|
||||
|
||||
func (x *Service_Container) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[11]
|
||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[15]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -708,7 +997,7 @@ func (x *Service_Container) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Service_Container.ProtoReflect.Descriptor instead.
|
||||
func (*Service_Container) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{8, 0}
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{10, 0}
|
||||
}
|
||||
|
||||
func (x *Service_Container) GetMachineId() string {
|
||||
@@ -732,58 +1021,81 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
|
||||
0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e,
|
||||
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x1a, 0x1b, 0x67, 0x6f,
|
||||
0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d,
|
||||
0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x24, 0x69, 0x6e, 0x74, 0x65, 0x72,
|
||||
0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f,
|
||||
0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22,
|
||||
0x85, 0x01, 0x0a, 0x0b, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12,
|
||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12,
|
||||
0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
|
||||
0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
|
||||
0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
|
||||
0x6b, 0x12, 0x24, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x04,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x08, 0x70,
|
||||
0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x22, 0xae, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x77,
|
||||
0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x75, 0x62,
|
||||
0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74,
|
||||
0x12, 0x2c, 0x0a, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x69,
|
||||
0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50,
|
||||
0x52, 0x0c, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x70, 0x12, 0x29,
|
||||
0x0a, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
|
||||
0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x09,
|
||||
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62,
|
||||
0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70,
|
||||
0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0x50, 0x0a, 0x1a, 0x43, 0x68, 0x65, 0x63,
|
||||
0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66,
|
||||
0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73,
|
||||
0x66, 0x69, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xc3, 0x01, 0x0a, 0x12, 0x49,
|
||||
0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d, 0x65,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e,
|
||||
0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x72, 0x65,
|
||||
0x66, 0x69, 0x78, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x26, 0x0a, 0x09,
|
||||
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x48, 0x00, 0x52, 0x08, 0x70, 0x75, 0x62, 0x6c,
|
||||
0x69, 0x63, 0x49, 0x70, 0x12, 0x26, 0x0a, 0x0e, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69,
|
||||
0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x0c,
|
||||
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x42, 0x12, 0x0a, 0x10,
|
||||
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67,
|
||||
0x22, 0x41, 0x0a, 0x13, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69,
|
||||
0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d,
|
||||
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68,
|
||||
0x69, 0x6e, 0x65, 0x22, 0x79, 0x0a, 0x12, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74,
|
||||
0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||
0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73,
|
||||
0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x24, 0x69, 0x6e, 0x74, 0x65,
|
||||
0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69,
|
||||
0x2f, 0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x22, 0x85, 0x01, 0x0a, 0x0b, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f,
|
||||
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
|
||||
0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||
0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4e, 0x65, 0x74, 0x77,
|
||||
0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f,
|
||||
0x72, 0x6b, 0x12, 0x24, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18,
|
||||
0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x08,
|
||||
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x22, 0xae, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74,
|
||||
0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x75,
|
||||
0x62, 0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65,
|
||||
0x74, 0x12, 0x2c, 0x0a, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f,
|
||||
0x69, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49,
|
||||
0x50, 0x52, 0x0c, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x70, 0x12,
|
||||
0x29, 0x0a, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03,
|
||||
0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52,
|
||||
0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75,
|
||||
0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09,
|
||||
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0x50, 0x0a, 0x1a, 0x43, 0x68, 0x65,
|
||||
0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73,
|
||||
0x66, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x61, 0x74, 0x69,
|
||||
0x73, 0x66, 0x69, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x81, 0x02, 0x0a, 0x12,
|
||||
0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d,
|
||||
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65,
|
||||
0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x72,
|
||||
0x65, 0x66, 0x69, 0x78, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x26, 0x0a,
|
||||
0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x48, 0x00, 0x52, 0x08, 0x70, 0x75, 0x62,
|
||||
0x6c, 0x69, 0x63, 0x49, 0x70, 0x12, 0x26, 0x0a, 0x0e, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f,
|
||||
0x69, 0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52,
|
||||
0x0c, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x12, 0x3c, 0x0a,
|
||||
0x13, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f,
|
||||
0x69, 0x6e, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61,
|
||||
0x72, 0x64, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x42, 0x12, 0x0a, 0x10, 0x70,
|
||||
0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22,
|
||||
0x41, 0x0a, 0x13, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e,
|
||||
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61,
|
||||
0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69,
|
||||
0x6e, 0x65, 0x22, 0xaa, 0x01, 0x0a, 0x12, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74,
|
||||
0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63,
|
||||
0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61,
|
||||
0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x37, 0x0a, 0x0e, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x5f, 0x6d,
|
||||
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52,
|
||||
0x0d, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x22, 0x25,
|
||||
0x0d, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x2f,
|
||||
0x0a, 0x14, 0x6d, 0x69, 0x6e, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x64, 0x62, 0x5f, 0x76,
|
||||
0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x6d, 0x69,
|
||||
0x6e, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22,
|
||||
0x49, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e,
|
||||
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x08, 0x6d, 0x61, 0x63,
|
||||
0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73,
|
||||
0x52, 0x08, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x22, 0x91, 0x01, 0x0a, 0x0e, 0x4d,
|
||||
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x29, 0x0a,
|
||||
0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08,
|
||||
0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68,
|
||||
0x69, 0x6e, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63,
|
||||
0x68, 0x69, 0x6e, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x64, 0x62,
|
||||
0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e,
|
||||
0x73, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x25,
|
||||
0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
|
||||
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x0e, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x65, 0x74, 0x52, 0x65,
|
||||
@@ -806,39 +1118,77 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
|
||||
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26,
|
||||
0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x0c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x07, 0x73,
|
||||
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x32, 0xc3, 0x03, 0x0a, 0x07, 0x4d, 0x61, 0x63, 0x68, 0x69,
|
||||
0x6e, 0x65, 0x12, 0x4d, 0x0a, 0x12, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65,
|
||||
0x71, 0x75, 0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
|
||||
0x1a, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72,
|
||||
0x65, 0x71, 0x75, 0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x40, 0x0a, 0x0b, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
|
||||
0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74,
|
||||
0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74,
|
||||
0x65, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75,
|
||||
0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f,
|
||||
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d,
|
||||
0x70, 0x74, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x16, 0x2e, 0x67,
|
||||
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45,
|
||||
0x6d, 0x70, 0x74, 0x79, 0x1a, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x33, 0x0a, 0x07, 0x49, 0x6e, 0x73, 0x70,
|
||||
0x65, 0x63, 0x74, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x10, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x32, 0x0a,
|
||||
0x05, 0x52, 0x65, 0x73, 0x65, 0x74, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x73,
|
||||
0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
|
||||
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74,
|
||||
0x79, 0x12, 0x49, 0x0a, 0x0e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63,
|
||||
0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||
0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72,
|
||||
0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35,
|
||||
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64,
|
||||
0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e,
|
||||
0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61,
|
||||
0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0xb2, 0x01, 0x0a, 0x1f, 0x49, 0x6e, 0x73, 0x70, 0x65,
|
||||
0x63, 0x74, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f,
|
||||
0x72, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e,
|
||||
0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d,
|
||||
0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79,
|
||||
0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72,
|
||||
0x74, 0x12, 0x28, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b,
|
||||
0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64,
|
||||
0x50, 0x65, 0x65, 0x72, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x22, 0x83, 0x02, 0x0a, 0x0d,
|
||||
0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x50, 0x65, 0x65, 0x72, 0x12, 0x1d, 0x0a,
|
||||
0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08,
|
||||
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
|
||||
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x13, 0x6c, 0x61, 0x73, 0x74,
|
||||
0x5f, 0x68, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
||||
0x70, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65,
|
||||
0x54, 0x69, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x5f,
|
||||
0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72, 0x65, 0x63,
|
||||
0x65, 0x69, 0x76, 0x65, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x72, 0x61,
|
||||
0x6e, 0x73, 0x6d, 0x69, 0x74, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x74, 0x42, 0x79, 0x74, 0x65, 0x73,
|
||||
0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x69, 0x70, 0x73, 0x18,
|
||||
0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70,
|
||||
0x73, 0x32, 0xe3, 0x04, 0x0a, 0x07, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x4d, 0x0a,
|
||||
0x12, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73, 0x69,
|
||||
0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1f, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73,
|
||||
0x69, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b,
|
||||
0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43,
|
||||
0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e,
|
||||
0x0a, 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x17, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x33,
|
||||
0x0a, 0x05, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a,
|
||||
0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x33, 0x0a, 0x07, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x12, 0x16,
|
||||
0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
|
||||
0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63,
|
||||
0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x45, 0x0a, 0x0e, 0x49, 0x6e, 0x73, 0x70,
|
||||
0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f,
|
||||
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70,
|
||||
0x74, 0x79, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
|
||||
0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x57, 0x0a, 0x17, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75,
|
||||
0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f,
|
||||
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70,
|
||||
0x74, 0x79, 0x1a, 0x24, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
|
||||
0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x05, 0x52, 0x65, 0x73, 0x65,
|
||||
0x74, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x49, 0x0a, 0x0e,
|
||||
0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x1a,
|
||||
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75,
|
||||
0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69,
|
||||
0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61,
|
||||
0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62,
|
||||
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -853,7 +1203,7 @@ func file_internal_machine_api_pb_machine_proto_rawDescGZIP() []byte {
|
||||
return file_internal_machine_api_pb_machine_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_internal_machine_api_pb_machine_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
|
||||
var file_internal_machine_api_pb_machine_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
|
||||
var file_internal_machine_api_pb_machine_proto_goTypes = []any{
|
||||
(*MachineInfo)(nil), // 0: api.MachineInfo
|
||||
(*NetworkConfig)(nil), // 1: api.NetworkConfig
|
||||
@@ -861,49 +1211,65 @@ var file_internal_machine_api_pb_machine_proto_goTypes = []any{
|
||||
(*InitClusterRequest)(nil), // 3: api.InitClusterRequest
|
||||
(*InitClusterResponse)(nil), // 4: api.InitClusterResponse
|
||||
(*JoinClusterRequest)(nil), // 5: api.JoinClusterRequest
|
||||
(*TokenResponse)(nil), // 6: api.TokenResponse
|
||||
(*ResetRequest)(nil), // 7: api.ResetRequest
|
||||
(*Service)(nil), // 8: api.Service
|
||||
(*InspectServiceRequest)(nil), // 9: api.InspectServiceRequest
|
||||
(*InspectServiceResponse)(nil), // 10: api.InspectServiceResponse
|
||||
(*Service_Container)(nil), // 11: api.Service.Container
|
||||
(*IP)(nil), // 12: api.IP
|
||||
(*IPPrefix)(nil), // 13: api.IPPrefix
|
||||
(*IPPort)(nil), // 14: api.IPPort
|
||||
(*emptypb.Empty)(nil), // 15: google.protobuf.Empty
|
||||
(*InspectMachineResponse)(nil), // 6: api.InspectMachineResponse
|
||||
(*MachineDetails)(nil), // 7: api.MachineDetails
|
||||
(*TokenResponse)(nil), // 8: api.TokenResponse
|
||||
(*ResetRequest)(nil), // 9: api.ResetRequest
|
||||
(*Service)(nil), // 10: api.Service
|
||||
(*InspectServiceRequest)(nil), // 11: api.InspectServiceRequest
|
||||
(*InspectServiceResponse)(nil), // 12: api.InspectServiceResponse
|
||||
(*InspectWireGuardNetworkResponse)(nil), // 13: api.InspectWireGuardNetworkResponse
|
||||
(*WireGuardPeer)(nil), // 14: api.WireGuardPeer
|
||||
(*Service_Container)(nil), // 15: api.Service.Container
|
||||
(*IP)(nil), // 16: api.IP
|
||||
(*IPPrefix)(nil), // 17: api.IPPrefix
|
||||
(*IPPort)(nil), // 18: api.IPPort
|
||||
(*Metadata)(nil), // 19: api.Metadata
|
||||
(*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp
|
||||
(*emptypb.Empty)(nil), // 21: google.protobuf.Empty
|
||||
}
|
||||
var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
|
||||
1, // 0: api.MachineInfo.network:type_name -> api.NetworkConfig
|
||||
12, // 1: api.MachineInfo.public_ip:type_name -> api.IP
|
||||
13, // 2: api.NetworkConfig.subnet:type_name -> api.IPPrefix
|
||||
12, // 3: api.NetworkConfig.management_ip:type_name -> api.IP
|
||||
14, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort
|
||||
13, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix
|
||||
12, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP
|
||||
0, // 7: api.InitClusterResponse.machine:type_name -> api.MachineInfo
|
||||
0, // 8: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
|
||||
0, // 9: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
|
||||
11, // 10: api.Service.containers:type_name -> api.Service.Container
|
||||
8, // 11: api.InspectServiceResponse.service:type_name -> api.Service
|
||||
15, // 12: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
|
||||
3, // 13: api.Machine.InitCluster:input_type -> api.InitClusterRequest
|
||||
5, // 14: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest
|
||||
15, // 15: api.Machine.Token:input_type -> google.protobuf.Empty
|
||||
15, // 16: api.Machine.Inspect:input_type -> google.protobuf.Empty
|
||||
7, // 17: api.Machine.Reset:input_type -> api.ResetRequest
|
||||
9, // 18: api.Machine.InspectService:input_type -> api.InspectServiceRequest
|
||||
2, // 19: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
|
||||
4, // 20: api.Machine.InitCluster:output_type -> api.InitClusterResponse
|
||||
15, // 21: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
|
||||
6, // 22: api.Machine.Token:output_type -> api.TokenResponse
|
||||
0, // 23: api.Machine.Inspect:output_type -> api.MachineInfo
|
||||
15, // 24: api.Machine.Reset:output_type -> google.protobuf.Empty
|
||||
10, // 25: api.Machine.InspectService:output_type -> api.InspectServiceResponse
|
||||
19, // [19:26] is the sub-list for method output_type
|
||||
12, // [12:19] is the sub-list for method input_type
|
||||
12, // [12:12] is the sub-list for extension type_name
|
||||
12, // [12:12] is the sub-list for extension extendee
|
||||
0, // [0:12] is the sub-list for field type_name
|
||||
16, // 1: api.MachineInfo.public_ip:type_name -> api.IP
|
||||
17, // 2: api.NetworkConfig.subnet:type_name -> api.IPPrefix
|
||||
16, // 3: api.NetworkConfig.management_ip:type_name -> api.IP
|
||||
18, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort
|
||||
17, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix
|
||||
16, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP
|
||||
18, // 7: api.InitClusterRequest.wireguard_endpoints:type_name -> api.IPPort
|
||||
0, // 8: api.InitClusterResponse.machine:type_name -> api.MachineInfo
|
||||
0, // 9: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
|
||||
0, // 10: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
|
||||
7, // 11: api.InspectMachineResponse.machines:type_name -> api.MachineDetails
|
||||
19, // 12: api.MachineDetails.metadata:type_name -> api.Metadata
|
||||
0, // 13: api.MachineDetails.machine:type_name -> api.MachineInfo
|
||||
15, // 14: api.Service.containers:type_name -> api.Service.Container
|
||||
10, // 15: api.InspectServiceResponse.service:type_name -> api.Service
|
||||
14, // 16: api.InspectWireGuardNetworkResponse.peers:type_name -> api.WireGuardPeer
|
||||
20, // 17: api.WireGuardPeer.last_handshake_time:type_name -> google.protobuf.Timestamp
|
||||
21, // 18: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
|
||||
3, // 19: api.Machine.InitCluster:input_type -> api.InitClusterRequest
|
||||
5, // 20: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest
|
||||
21, // 21: api.Machine.Token:input_type -> google.protobuf.Empty
|
||||
21, // 22: api.Machine.Inspect:input_type -> google.protobuf.Empty
|
||||
21, // 23: api.Machine.InspectMachine:input_type -> google.protobuf.Empty
|
||||
21, // 24: api.Machine.InspectWireGuardNetwork:input_type -> google.protobuf.Empty
|
||||
9, // 25: api.Machine.Reset:input_type -> api.ResetRequest
|
||||
11, // 26: api.Machine.InspectService:input_type -> api.InspectServiceRequest
|
||||
2, // 27: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
|
||||
4, // 28: api.Machine.InitCluster:output_type -> api.InitClusterResponse
|
||||
21, // 29: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
|
||||
8, // 30: api.Machine.Token:output_type -> api.TokenResponse
|
||||
0, // 31: api.Machine.Inspect:output_type -> api.MachineInfo
|
||||
6, // 32: api.Machine.InspectMachine:output_type -> api.InspectMachineResponse
|
||||
13, // 33: api.Machine.InspectWireGuardNetwork:output_type -> api.InspectWireGuardNetworkResponse
|
||||
21, // 34: api.Machine.Reset:output_type -> google.protobuf.Empty
|
||||
12, // 35: api.Machine.InspectService:output_type -> api.InspectServiceResponse
|
||||
27, // [27:36] is the sub-list for method output_type
|
||||
18, // [18:27] is the sub-list for method input_type
|
||||
18, // [18:18] is the sub-list for extension type_name
|
||||
18, // [18:18] is the sub-list for extension extendee
|
||||
0, // [0:18] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_internal_machine_api_pb_machine_proto_init() }
|
||||
@@ -986,7 +1352,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_machine_proto_msgTypes[6].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*TokenResponse); i {
|
||||
switch v := v.(*InspectMachineResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@@ -998,7 +1364,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_machine_proto_msgTypes[7].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*ResetRequest); i {
|
||||
switch v := v.(*MachineDetails); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@@ -1010,7 +1376,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_machine_proto_msgTypes[8].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*Service); i {
|
||||
switch v := v.(*TokenResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@@ -1022,7 +1388,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_machine_proto_msgTypes[9].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*InspectServiceRequest); i {
|
||||
switch v := v.(*ResetRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@@ -1034,7 +1400,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_machine_proto_msgTypes[10].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*InspectServiceResponse); i {
|
||||
switch v := v.(*Service); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@@ -1046,6 +1412,54 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_machine_proto_msgTypes[11].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*InspectServiceRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_machine_proto_msgTypes[12].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*InspectServiceResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_machine_proto_msgTypes[13].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*InspectWireGuardNetworkResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_machine_proto_msgTypes[14].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*WireGuardPeer); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_machine_proto_msgTypes[15].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*Service_Container); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
@@ -1068,7 +1482,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_internal_machine_api_pb_machine_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 12,
|
||||
NumMessages: 16,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ package api;
|
||||
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "internal/machine/api/pb/common.proto";
|
||||
|
||||
service Machine {
|
||||
@@ -13,7 +14,12 @@ service Machine {
|
||||
rpc InitCluster(InitClusterRequest) returns (InitClusterResponse);
|
||||
rpc JoinCluster(JoinClusterRequest) returns (google.protobuf.Empty);
|
||||
rpc Token(google.protobuf.Empty) returns (TokenResponse);
|
||||
// Deprecated: use InspectMachine instead.
|
||||
rpc Inspect(google.protobuf.Empty) returns (MachineInfo);
|
||||
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
||||
rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse);
|
||||
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
|
||||
rpc InspectWireGuardNetwork(google.protobuf.Empty) returns (InspectWireGuardNetworkResponse);
|
||||
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
|
||||
rpc Reset(ResetRequest) returns (google.protobuf.Empty);
|
||||
|
||||
@@ -49,6 +55,9 @@ message InitClusterRequest {
|
||||
IP public_ip = 3;
|
||||
bool public_ip_auto = 4;
|
||||
}
|
||||
|
||||
// Optional WireGuard endpoints other machines will use to connect to this machine instead of auto-discovered ones.
|
||||
repeated IPPort wireguard_endpoints = 5;
|
||||
}
|
||||
|
||||
message InitClusterResponse {
|
||||
@@ -58,6 +67,20 @@ message InitClusterResponse {
|
||||
message JoinClusterRequest {
|
||||
MachineInfo machine = 1;
|
||||
repeated MachineInfo other_machines = 3;
|
||||
// Minimum store database version the new machine should sync to before starting cluster operations.
|
||||
int64 min_store_db_version = 4;
|
||||
}
|
||||
|
||||
message InspectMachineResponse {
|
||||
// Must contain only one repeated messages field to allow broadcasting InspectMachine requests to multiple machines.
|
||||
repeated MachineDetails machines = 1;
|
||||
}
|
||||
|
||||
message MachineDetails {
|
||||
Metadata metadata = 1;
|
||||
MachineInfo machine = 2;
|
||||
// Current Corrosion cr-sqlite database version (Lamport timestamp) of the cluster store.
|
||||
int64 store_db_version = 3;
|
||||
}
|
||||
|
||||
message TokenResponse {
|
||||
@@ -88,3 +111,19 @@ message InspectServiceRequest {
|
||||
message InspectServiceResponse {
|
||||
Service service = 1;
|
||||
}
|
||||
|
||||
message InspectWireGuardNetworkResponse {
|
||||
string interface_name = 1;
|
||||
bytes public_key = 2;
|
||||
int32 listen_port = 3;
|
||||
repeated WireGuardPeer peers = 4;
|
||||
}
|
||||
|
||||
message WireGuardPeer {
|
||||
bytes public_key = 1;
|
||||
string endpoint = 2;
|
||||
google.protobuf.Timestamp last_handshake_time = 3;
|
||||
int64 receive_bytes = 4;
|
||||
int64 transmit_bytes = 5;
|
||||
repeated string allowed_ips = 6;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ const (
|
||||
Machine_JoinCluster_FullMethodName = "/api.Machine/JoinCluster"
|
||||
Machine_Token_FullMethodName = "/api.Machine/Token"
|
||||
Machine_Inspect_FullMethodName = "/api.Machine/Inspect"
|
||||
Machine_InspectMachine_FullMethodName = "/api.Machine/InspectMachine"
|
||||
Machine_InspectWireGuardNetwork_FullMethodName = "/api.Machine/InspectWireGuardNetwork"
|
||||
Machine_Reset_FullMethodName = "/api.Machine/Reset"
|
||||
Machine_InspectService_FullMethodName = "/api.Machine/InspectService"
|
||||
)
|
||||
@@ -38,7 +40,12 @@ type MachineClient interface {
|
||||
InitCluster(ctx context.Context, in *InitClusterRequest, opts ...grpc.CallOption) (*InitClusterResponse, error)
|
||||
JoinCluster(ctx context.Context, in *JoinClusterRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
Token(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*TokenResponse, error)
|
||||
// Deprecated: use InspectMachine instead.
|
||||
Inspect(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*MachineInfo, error)
|
||||
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
||||
InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error)
|
||||
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
|
||||
InspectWireGuardNetwork(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectWireGuardNetworkResponse, error)
|
||||
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
|
||||
Reset(ctx context.Context, in *ResetRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
InspectService(ctx context.Context, in *InspectServiceRequest, opts ...grpc.CallOption) (*InspectServiceResponse, error)
|
||||
@@ -102,6 +109,26 @@ func (c *machineClient) Inspect(ctx context.Context, in *emptypb.Empty, opts ...
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *machineClient) InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(InspectMachineResponse)
|
||||
err := c.cc.Invoke(ctx, Machine_InspectMachine_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *machineClient) InspectWireGuardNetwork(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectWireGuardNetworkResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(InspectWireGuardNetworkResponse)
|
||||
err := c.cc.Invoke(ctx, Machine_InspectWireGuardNetwork_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *machineClient) Reset(ctx context.Context, in *ResetRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(emptypb.Empty)
|
||||
@@ -131,7 +158,12 @@ type MachineServer interface {
|
||||
InitCluster(context.Context, *InitClusterRequest) (*InitClusterResponse, error)
|
||||
JoinCluster(context.Context, *JoinClusterRequest) (*emptypb.Empty, error)
|
||||
Token(context.Context, *emptypb.Empty) (*TokenResponse, error)
|
||||
// Deprecated: use InspectMachine instead.
|
||||
Inspect(context.Context, *emptypb.Empty) (*MachineInfo, error)
|
||||
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
||||
InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error)
|
||||
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
|
||||
InspectWireGuardNetwork(context.Context, *emptypb.Empty) (*InspectWireGuardNetworkResponse, error)
|
||||
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
|
||||
Reset(context.Context, *ResetRequest) (*emptypb.Empty, error)
|
||||
InspectService(context.Context, *InspectServiceRequest) (*InspectServiceResponse, error)
|
||||
@@ -160,6 +192,12 @@ func (UnimplementedMachineServer) Token(context.Context, *emptypb.Empty) (*Token
|
||||
func (UnimplementedMachineServer) Inspect(context.Context, *emptypb.Empty) (*MachineInfo, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Inspect not implemented")
|
||||
}
|
||||
func (UnimplementedMachineServer) InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method InspectMachine not implemented")
|
||||
}
|
||||
func (UnimplementedMachineServer) InspectWireGuardNetwork(context.Context, *emptypb.Empty) (*InspectWireGuardNetworkResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method InspectWireGuardNetwork not implemented")
|
||||
}
|
||||
func (UnimplementedMachineServer) Reset(context.Context, *ResetRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Reset not implemented")
|
||||
}
|
||||
@@ -277,6 +315,42 @@ func _Machine_Inspect_Handler(srv interface{}, ctx context.Context, dec func(int
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Machine_InspectMachine_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.(MachineServer).InspectMachine(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Machine_InspectMachine_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MachineServer).InspectMachine(ctx, req.(*emptypb.Empty))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Machine_InspectWireGuardNetwork_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.(MachineServer).InspectWireGuardNetwork(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Machine_InspectWireGuardNetwork_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MachineServer).InspectWireGuardNetwork(ctx, req.(*emptypb.Empty))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Machine_Reset_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ResetRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -340,6 +414,14 @@ var Machine_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "Inspect",
|
||||
Handler: _Machine_Inspect_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "InspectMachine",
|
||||
Handler: _Machine_InspectMachine_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "InspectWireGuardNetwork",
|
||||
Handler: _Machine_InspectWireGuardNetwork_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Reset",
|
||||
Handler: _Machine_Reset_Handler,
|
||||
|
||||
@@ -80,6 +80,8 @@ func (b *RemoteBackend) GetConnection(ctx context.Context, _ string) (context.Co
|
||||
backoffConfig.MaxDelay = 15 * time.Second
|
||||
|
||||
var err error
|
||||
// This client keeps retrying connection in the background indefinitely even after the first connection fails
|
||||
// and the Unavailable error is returned to the caller.
|
||||
b.conn, err = grpc.NewClient(
|
||||
b.target,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
@@ -88,7 +90,7 @@ func (b *RemoteBackend) GetConnection(ctx context.Context, _ string) (context.Co
|
||||
// Not published as a constant in gRPC library.
|
||||
// See: https://github.com/grpc/grpc-go/blob/d5dee5fdbdeb52f6ea10b37b2cc7ce37814642d7/clientconn.go#L55-L56
|
||||
// Each connection attempt can take up to MinConnectTimeout.
|
||||
MinConnectTimeout: 20 * time.Second,
|
||||
MinConnectTimeout: 10 * time.Second,
|
||||
}),
|
||||
grpc.WithDefaultCallOptions(
|
||||
grpc.ForceCodecV2(proxy.Codec()),
|
||||
|
||||
@@ -12,14 +12,16 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
const (
|
||||
caddyfileHeader = `# This file is autogenerated by Uncloud based on the configuration of running services.
|
||||
# Do not edit manually. Any manual changes will be overwritten on the next update.
|
||||
caddyfileHeaderFmt = `# Caddyfile autogenerated by Uncloud (DO NOT EDIT): %s
|
||||
# Automatically updated on service or health status changes.
|
||||
# Docs: https://uncloud.run/docs/concepts/ingress/overview
|
||||
`
|
||||
caddyfileTemplate = `# Health check endpoint to verify Caddy reachability on this machine.
|
||||
http:// {
|
||||
@@ -56,7 +58,9 @@ https://{{$hostname}} {
|
||||
}{{end}}
|
||||
`
|
||||
caddyfileUnavailabeFooter = `# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
|
||||
# or the latest generated config is invalid. Please check the Caddy logs if it's running.
|
||||
# (not accessible via the shared admin socket /run/uncloud/caddy/admin.sock) or the latest
|
||||
# generated config is invalid. Please check the service 'caddy' is running (uc inspect caddy)
|
||||
# and its logs for more details (uc logs caddy).
|
||||
`
|
||||
)
|
||||
|
||||
@@ -120,6 +124,7 @@ func (g *CaddyfileGenerator) Generate(
|
||||
return "", fmt.Errorf("generate base Caddyfile from service ports: %w", err)
|
||||
}
|
||||
|
||||
caddyfileHeader := fmt.Sprintf(caddyfileHeaderFmt, time.Now().UTC().Format(time.RFC3339))
|
||||
if !includeCustom {
|
||||
return fmt.Sprintf("%s\n%s\n%s", caddyfileHeader, caddyfile, caddyfileUnavailabeFooter), nil
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ package caddyconfig
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
@@ -18,8 +18,17 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testCaddyfileHeader = `# This file is autogenerated by Uncloud based on the configuration of running services.
|
||||
# Do not edit manually. Any manual changes will be overwritten on the next update.
|
||||
// generatedTimestampRegex matches the "(DO NOT EDIT): <RFC3339 timestamp>" part of the header.
|
||||
var generatedTimestampRegex = regexp.MustCompile(`\(DO NOT EDIT\): \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`)
|
||||
|
||||
// normaliseGeneratedTimestamp replaces dynamic timestamps with a placeholder for test comparison.
|
||||
func normaliseGeneratedTimestamp(caddyfile string) string {
|
||||
return generatedTimestampRegex.ReplaceAllString(caddyfile, "(DO NOT EDIT): TIMESTAMP_PLACEHOLDER")
|
||||
}
|
||||
|
||||
const testCaddyfileHeader = `# Caddyfile autogenerated by Uncloud (DO NOT EDIT): TIMESTAMP_PLACEHOLDER
|
||||
# Automatically updated on service or health status changes.
|
||||
# Docs: https://uncloud.run/docs/concepts/ingress/overview
|
||||
|
||||
# Health check endpoint to verify Caddy reachability on this machine.
|
||||
http:// {
|
||||
@@ -191,7 +200,7 @@ https://secure.example.com {
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match")
|
||||
assert.Equal(t, tt.want, normaliseGeneratedTimestamp(config), "Generated Caddyfile doesn't match")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -217,8 +226,9 @@ func TestCaddyfileGeneratorWithCustomConfigs(t *testing.T) {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: `# This file is autogenerated by Uncloud based on the configuration of running services.
|
||||
# Do not edit manually. Any manual changes will be overwritten on the next update.
|
||||
want: `# Caddyfile autogenerated by Uncloud (DO NOT EDIT): TIMESTAMP_PLACEHOLDER
|
||||
# Automatically updated on service or health status changes.
|
||||
# Docs: https://uncloud.run/docs/concepts/ingress/overview
|
||||
|
||||
# User-defined global config from service 'caddy'.
|
||||
# Global Caddy configuration
|
||||
@@ -413,8 +423,9 @@ web.example.com {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: `# This file is autogenerated by Uncloud based on the configuration of running services.
|
||||
# Do not edit manually. Any manual changes will be overwritten on the next update.
|
||||
want: `# Caddyfile autogenerated by Uncloud (DO NOT EDIT): TIMESTAMP_PLACEHOLDER
|
||||
# Automatically updated on service or health status changes.
|
||||
# Docs: https://uncloud.run/docs/concepts/ingress/overview
|
||||
|
||||
# User-defined global config from service 'caddy'.
|
||||
# Global config
|
||||
@@ -650,8 +661,9 @@ badconfig.com {
|
||||
time.Now(),
|
||||
),
|
||||
},
|
||||
want: `# This file is autogenerated by Uncloud based on the configuration of running services.
|
||||
# Do not edit manually. Any manual changes will be overwritten on the next update.
|
||||
want: `# Caddyfile autogenerated by Uncloud (DO NOT EDIT): TIMESTAMP_PLACEHOLDER
|
||||
# Automatically updated on service or health status changes.
|
||||
# Docs: https://uncloud.run/docs/concepts/ingress/overview
|
||||
|
||||
# User-defined global config from service 'caddy'.
|
||||
# Global config from test machine
|
||||
@@ -832,7 +844,7 @@ valid.example.com {
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match")
|
||||
assert.Equal(t, tt.want, normaliseGeneratedTimestamp(config), "Generated Caddyfile doesn't match")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -848,15 +860,15 @@ func newContainerRecordWithCaddyConfig(serviceName, ip, caddyConfig, machineID s
|
||||
return store.ContainerRecord{
|
||||
Container: api.ServiceContainer{
|
||||
Container: api.Container{
|
||||
ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
ID: serviceName + "-" + ip, // Add ID for stable sorting
|
||||
State: &types.ContainerState{
|
||||
State: &container.State{
|
||||
Running: true,
|
||||
},
|
||||
Created: created.UTC().Format(time.RFC3339Nano),
|
||||
},
|
||||
NetworkSettings: &types.NetworkSettings{
|
||||
NetworkSettings: &container.NetworkSettings{
|
||||
Networks: map[string]*network.EndpointSettings{
|
||||
docker.NetworkName: {
|
||||
IPAddress: ip,
|
||||
@@ -927,7 +939,9 @@ http://api.example.com {
|
||||
}
|
||||
|
||||
# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
|
||||
# or the latest generated config is invalid. Please check the Caddy logs if it's running.
|
||||
# (not accessible via the shared admin socket /run/uncloud/caddy/admin.sock) or the latest
|
||||
# generated config is invalid. Please check the service 'caddy' is running (uc inspect caddy)
|
||||
# and its logs for more details (uc logs caddy).
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -951,7 +965,9 @@ http://api.example.com {
|
||||
}
|
||||
|
||||
# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
|
||||
# or the latest generated config is invalid. Please check the Caddy logs if it's running.
|
||||
# (not accessible via the shared admin socket /run/uncloud/caddy/admin.sock) or the latest
|
||||
# generated config is invalid. Please check the service 'caddy' is running (uc inspect caddy)
|
||||
# and its logs for more details (uc logs caddy).
|
||||
`,
|
||||
},
|
||||
}
|
||||
@@ -965,7 +981,7 @@ http://api.example.com {
|
||||
config, err := generator.Generate(ctx, tt.containers, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match")
|
||||
assert.Equal(t, tt.want, normaliseGeneratedTimestamp(config), "Generated Caddyfile doesn't match")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -975,15 +991,15 @@ func newContainerRecordWithPorts(serviceName, ip string, ports []string, machine
|
||||
return store.ContainerRecord{
|
||||
Container: api.ServiceContainer{
|
||||
Container: api.Container{
|
||||
ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
ID: serviceName + "-" + ip, // Add ID for stable sorting
|
||||
State: &types.ContainerState{
|
||||
State: &container.State{
|
||||
Running: true,
|
||||
},
|
||||
Created: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
},
|
||||
NetworkSettings: &types.NetworkSettings{
|
||||
NetworkSettings: &container.NetworkSettings{
|
||||
Networks: map[string]*network.EndpointSettings{
|
||||
docker.NetworkName: {
|
||||
IPAddress: ip,
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
@@ -334,13 +333,13 @@ func TestGenerateJSONConfig(t *testing.T) {
|
||||
|
||||
func newContainer(ip string, ports ...string) api.ServiceContainer {
|
||||
portsLabel := strings.Join(ports, ",")
|
||||
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Running: true,
|
||||
},
|
||||
},
|
||||
NetworkSettings: &types.NetworkSettings{
|
||||
NetworkSettings: &container.NetworkSettings{
|
||||
Networks: map[string]*network.EndpointSettings{
|
||||
docker.NetworkName: {
|
||||
IPAddress: ip,
|
||||
@@ -357,13 +356,13 @@ func newContainer(ip string, ports ...string) api.ServiceContainer {
|
||||
|
||||
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
|
||||
portsLabel := strings.Join(ports, ",")
|
||||
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Running: true,
|
||||
},
|
||||
},
|
||||
NetworkSettings: &types.NetworkSettings{
|
||||
NetworkSettings: &container.NetworkSettings{
|
||||
Networks: map[string]*network.EndpointSettings{
|
||||
"other-network": {
|
||||
IPAddress: "172.17.0.2",
|
||||
|
||||
@@ -27,9 +27,9 @@ func (s *Server) GetConfig(ctx context.Context, _ *emptypb.Empty) (*pb.GetCaddyC
|
||||
caddyfile, modifiedAt, err := s.service.Caddyfile()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &pb.GetCaddyConfigResponse{
|
||||
|
||||
+253
-52
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/machine/firewall"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/unregistry"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
@@ -40,11 +41,15 @@ type clusterController struct {
|
||||
dockerCtrl *docker.Controller
|
||||
// dockerReady is signalled when Docker is configured and ready for containers.
|
||||
dockerReady chan<- struct{}
|
||||
// clusterReady is signalled when the cluster controller has finished initializing all components.
|
||||
clusterReady chan<- struct{}
|
||||
caddyconfigCtrl *caddyconfig.Controller
|
||||
|
||||
// dnsServer is the embedded internal DNS server for the cluster listening on the machine IP.
|
||||
dnsServer *dns.Server
|
||||
dnsResolver *dns.ClusterResolver
|
||||
// unregistry is the embedded container registry that uses the local Docker (containerd) image store as its backend.
|
||||
unregistry *unregistry.Registry
|
||||
|
||||
// stopped is a channel that is closed when the controller is stopped.
|
||||
stopped chan struct{}
|
||||
@@ -57,9 +62,11 @@ func newClusterController(
|
||||
corroService corroservice.Service,
|
||||
dockerService *docker.Service,
|
||||
dockerReady chan<- struct{},
|
||||
clusterReady chan<- struct{},
|
||||
caddyfileCtrl *caddyconfig.Controller,
|
||||
dnsServer *dns.Server,
|
||||
dnsResolver *dns.ClusterResolver,
|
||||
unregistry *unregistry.Registry,
|
||||
) (*clusterController, error) {
|
||||
slog.Info("Starting WireGuard network.")
|
||||
wgnet, err := network.NewWireGuardNetwork()
|
||||
@@ -77,9 +84,11 @@ func newClusterController(
|
||||
corroService: corroService,
|
||||
dockerCtrl: docker.NewController(state.ID, dockerService, store),
|
||||
dockerReady: dockerReady,
|
||||
clusterReady: clusterReady,
|
||||
caddyconfigCtrl: caddyfileCtrl,
|
||||
dnsServer: dnsServer,
|
||||
dnsResolver: dnsResolver,
|
||||
unregistry: unregistry,
|
||||
stopped: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
@@ -87,7 +96,7 @@ func newClusterController(
|
||||
func (cc *clusterController) Run(ctx context.Context) error {
|
||||
defer close(cc.stopped)
|
||||
|
||||
if err := firewall.ConfigureIptablesChains(); err != nil {
|
||||
if err := firewall.ConfigureIptablesChains(network.MachineIP(cc.state.Network.Subnet)); err != nil {
|
||||
return fmt.Errorf("configure iptables chains: %w", err)
|
||||
}
|
||||
|
||||
@@ -118,6 +127,21 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
||||
|
||||
errGroup, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
// Start the WireGuard control loop before waiting for store sync. This ensures endpoint rotation happens
|
||||
// while waiting, allowing Corrosion to connect to peers.
|
||||
errGroup.Go(func() error {
|
||||
if err := cc.wgnet.Run(ctx); err != nil {
|
||||
return fmt.Errorf("WireGuard network failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Watch for WireGuard peer endpoint changes and update the machine state accordingly.
|
||||
errGroup.Go(func() error {
|
||||
cc.handleEndpointChanges(ctx)
|
||||
return nil
|
||||
})
|
||||
|
||||
// Start the network API server. Assume the management IP can't be changed when the network is running.
|
||||
apiAddr := net.JoinHostPort(cc.state.Network.ManagementIP.String(), strconv.Itoa(constants.MachineAPIPort))
|
||||
listener, err := net.Listen("tcp", apiAddr)
|
||||
@@ -132,6 +156,22 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
// Wait for the store database to sync to the minimum version before starting store-dependent components.
|
||||
// This prevents issues with using partially replicated data when the machine just joined the cluster,
|
||||
// e.g., an empty machine list causing WireGuard peer misconfiguration.
|
||||
cc.waitStoreSync(ctx)
|
||||
|
||||
// Check if waitStoreSync exited because the context was cancelled. Return early in that case.
|
||||
if ctx.Err() != nil {
|
||||
cc.stopAPIServer()
|
||||
|
||||
err := errGroup.Wait()
|
||||
if corroErr := cc.stopCorrosion(); corroErr != nil {
|
||||
err = errors.Join(err, corroErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
errGroup.Go(func() error {
|
||||
slog.Info("Starting embedded DNS resolver.")
|
||||
if err := cc.dnsResolver.Run(ctx); err != nil {
|
||||
@@ -164,44 +204,6 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
// Watch for endpoint changes and update the machine state accordingly.
|
||||
errGroup.Go(func() error {
|
||||
for {
|
||||
select {
|
||||
case e, ok := <-cc.endpointChanges:
|
||||
if !ok {
|
||||
// The channel was closed, stop watching for changes.
|
||||
cc.endpointChanges = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
cc.state.mu.Lock()
|
||||
for i := range cc.state.Network.Peers {
|
||||
if cc.state.Network.Peers[i].PublicKey.Equal(e.PublicKey) {
|
||||
cc.state.Network.Peers[i].Endpoint = &e.Endpoint
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := cc.state.Save(); err != nil {
|
||||
slog.Error("Failed to save machine state.", "err", err)
|
||||
}
|
||||
cc.state.mu.Unlock()
|
||||
|
||||
slog.Debug("Preserved endpoint change in the machine state.",
|
||||
"public_key", e.PublicKey, "endpoint", e.Endpoint)
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
errGroup.Go(func() error {
|
||||
if err := cc.wgnet.Run(ctx); err != nil {
|
||||
return fmt.Errorf("WireGuard network failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
errGroup.Go(func() error {
|
||||
slog.Info("Starting caddyconfig controller.")
|
||||
if err := cc.caddyconfigCtrl.Run(ctx); err != nil {
|
||||
@@ -210,29 +212,84 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
// Wait for the context to be done and stop the network API server.
|
||||
if cc.unregistry != nil {
|
||||
errGroup.Go(func() error {
|
||||
slog.Info("Starting unregistry server.")
|
||||
if err := cc.unregistry.ListenAndServe(); err != nil {
|
||||
return fmt.Errorf("unregistry server failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Signal that the cluster controller has finished starting all components.
|
||||
close(cc.clusterReady)
|
||||
slog.Info("Cluster controller finished starting all components.")
|
||||
// Wait for the context to be done and stop all servers and controllers.
|
||||
<-ctx.Done()
|
||||
slog.Info("Stopping network API server.")
|
||||
// TODO: implement timeout for graceful shutdown.
|
||||
cc.server.GracefulStop()
|
||||
slog.Info("Network API server stopped.")
|
||||
|
||||
cc.stopAPIServer()
|
||||
|
||||
// Stop the unregistry server with a timeout if it was started.
|
||||
if cc.unregistry != nil {
|
||||
unregTimeout := 30 * time.Second
|
||||
slog.Info("Stopping unregistry server.", "timeout", unregTimeout)
|
||||
unregCtx, cancel := context.WithTimeout(context.Background(), unregTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err = cc.unregistry.Shutdown(unregCtx); err != nil {
|
||||
return fmt.Errorf("unregistry server forced to shutdown: %w", err)
|
||||
}
|
||||
slog.Info("Unregistry server stopped.")
|
||||
}
|
||||
|
||||
// Wait for all controllers to finish.
|
||||
err = errGroup.Wait()
|
||||
|
||||
// It's safe to stop the Corrosion service after the controllers depending on it and API server are stopped.
|
||||
// Use a new context with a timeout as the current context is already canceled.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if corroErr := cc.corroService.Stop(ctx); corroErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("stop corrosion service: %w", corroErr))
|
||||
} else {
|
||||
slog.Info("Corrosion service stopped.")
|
||||
// Stop Corrosion after all controllers depending on it and API server are stopped.
|
||||
if corroErr := cc.stopCorrosion(); corroErr != nil {
|
||||
err = errors.Join(err, corroErr)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// stopAPIServer gracefully stops the network API server with a timeout.
|
||||
func (cc *clusterController) stopAPIServer() {
|
||||
timeout := 10 * time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
slog.Info("Stopping network API server.")
|
||||
cc.server.GracefulStop()
|
||||
close(stopped)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Warn("Network API server graceful stop timed out, forcing stop.", "timeout", timeout)
|
||||
cc.server.Stop()
|
||||
case <-stopped:
|
||||
}
|
||||
|
||||
slog.Info("Network API server stopped.")
|
||||
}
|
||||
|
||||
// stopCorrosion stops the Corrosion service with a timeout.
|
||||
func (cc *clusterController) stopCorrosion() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := cc.corroService.Stop(ctx); err != nil {
|
||||
return fmt.Errorf("stop corrosion service: %w", err)
|
||||
}
|
||||
slog.Info("Corrosion service stopped.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDockerNetwork ensures that the Docker network is configured and ready for containers.
|
||||
func (cc *clusterController) ensureDockerNetwork(ctx context.Context) error {
|
||||
if err := cc.dockerCtrl.WaitDaemonReady(ctx); err != nil {
|
||||
@@ -253,6 +310,140 @@ func (cc *clusterController) ensureDockerNetwork(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleEndpointChanges watches for WireGuard peer endpoint changes and persists them to the machine state.
|
||||
func (cc *clusterController) handleEndpointChanges(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case e, ok := <-cc.endpointChanges:
|
||||
if !ok {
|
||||
// The channel was closed, stop watching for changes.
|
||||
cc.endpointChanges = nil
|
||||
return
|
||||
}
|
||||
|
||||
cc.state.mu.Lock()
|
||||
for i := range cc.state.Network.Peers {
|
||||
if cc.state.Network.Peers[i].PublicKey.Equal(e.PublicKey) {
|
||||
cc.state.Network.Peers[i].Endpoint = &e.Endpoint
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := cc.state.Save(); err != nil {
|
||||
slog.Error("Failed to save machine state.", "err", err)
|
||||
}
|
||||
cc.state.mu.Unlock()
|
||||
|
||||
slog.Debug("Preserved endpoint change in the machine state.",
|
||||
"public_key", e.PublicKey, "endpoint", e.Endpoint)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitStoreSync waits for the store database to sync to the minimum required DB version if set in the machine state.
|
||||
// Blocks until synced or context is cancelled.
|
||||
func (cc *clusterController) waitStoreSync(ctx context.Context) {
|
||||
minVersion := cc.state.MinStoreDBVersion
|
||||
if minVersion == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Waiting for the initial cluster store sync.", "min_version", minVersion)
|
||||
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
var (
|
||||
lastVersion int64
|
||||
lastLogTime time.Time
|
||||
lastErrLogTime time.Time
|
||||
)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
version, err := cc.store.DBVersion(ctx)
|
||||
if err != nil {
|
||||
// Log errors at most once every 5 seconds.
|
||||
if time.Since(lastErrLogTime) >= 5*time.Second {
|
||||
slog.Error("Failed to get the cluster store DB version, retrying.", "err", err)
|
||||
lastErrLogTime = time.Now()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if version >= minVersion {
|
||||
// Clear MinStoreDBVersion so next restart doesn't wait for sync.
|
||||
cc.state.mu.Lock()
|
||||
cc.state.MinStoreDBVersion = 0
|
||||
if err := cc.state.Save(); err != nil {
|
||||
slog.Error("Failed to save machine state after the initial cluster store sync.", "err", err)
|
||||
}
|
||||
cc.state.mu.Unlock()
|
||||
|
||||
// Wait for all known missing changes to be synced before returning.
|
||||
// TODO: reevaluate if this is necessary after migrating to the latest Corrosion version:
|
||||
// https://github.com/psviderski/uncloud/issues/172
|
||||
// This works on the best effort basis as the missing changes may not be yet known when we start
|
||||
// checking it after reaching the minimum version.
|
||||
// Reaching the minimum version doesn't guarantee that the store is actually synced to
|
||||
// the state we observed on the source node. db_version is a machine-local Lamport clock.
|
||||
// When changes are received from a remote machine, the local db_version is set to
|
||||
// max(local_db_version, incoming_db_version) + 1 for each applied transaction. This means
|
||||
// the new machine's db_version can jump well past the source machine's db_version on the very
|
||||
// first batch of replicated changes, without having received all changes from all machines
|
||||
// in the cluster.
|
||||
cc.waitKnownMissingChanges(ctx)
|
||||
|
||||
if ver, verErr := cc.store.DBVersion(ctx); verErr == nil {
|
||||
version = ver
|
||||
}
|
||||
slog.Info("Cluster store completed the initial sync.", "version", version, "min_version", minVersion)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Log progress only once a second.
|
||||
if version != lastVersion && time.Since(lastLogTime) >= 1*time.Second {
|
||||
slog.Info("Syncing cluster store.", "version", version, "min_version", minVersion)
|
||||
lastLogTime = time.Now()
|
||||
lastVersion = version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitKnownMissingChanges polls the store until all known missing changes have been synced.
|
||||
func (cc *clusterController) waitKnownMissingChanges(ctx context.Context) {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
changes, err := cc.store.KnownMissingChanges(ctx)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get known missing changes from the cluster store, skipping check.",
|
||||
"err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
slog.Debug("All known missing changes have been synced to the cluster store.")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("Waiting for known missing changes to be synced to the cluster store.", "remaining",
|
||||
len(changes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncDockerContainers watches local Docker containers and syncs them to the cluster store.
|
||||
// TODO: move this to the Docker controller.
|
||||
func (cc *clusterController) syncDockerContainers(ctx context.Context) error {
|
||||
@@ -312,6 +503,8 @@ func (cc *clusterController) handleMachineChanges(ctx context.Context) error {
|
||||
|
||||
// The machine store may be empty when a machine first joins the cluster, before store synchronization
|
||||
// completes. Skip configuration now and apply it when the store changes are received.
|
||||
// TODO: remove this check after ensuring the store is actually synced to the latest known state at this point.
|
||||
// See TODO in waitStoreSync.
|
||||
if len(machines) > 0 {
|
||||
slog.Info("Reconfiguring network peers with the current machines.", "machines", len(machines))
|
||||
if err = cc.configurePeers(machines); err != nil {
|
||||
@@ -330,6 +523,14 @@ func (cc *clusterController) handleMachineChanges(ctx context.Context) error {
|
||||
slog.Error("Failed to list machines.", "err", err)
|
||||
continue
|
||||
}
|
||||
// Skip reconfiguration if the machines list is empty. This can happen when joining the cluster.
|
||||
// Corrosion can notifies about table changes before the data is fully replicated.
|
||||
// Reconfiguring with an empty list would remove all peers and lock this machine out of the cluster.
|
||||
// See https://github.com/psviderski/uncloud/issues/155.
|
||||
if len(machines) == 0 {
|
||||
slog.Debug("Skipping peer reconfiguration: machines list in store is empty.")
|
||||
continue
|
||||
}
|
||||
if err = cc.configurePeers(machines); err != nil {
|
||||
slog.Error("Failed to configure peers.", "err", err)
|
||||
}
|
||||
|
||||
@@ -26,12 +26,19 @@ type Cluster struct {
|
||||
corroAdmin *corrosion.AdminClient
|
||||
// machineID is the ID of the current machine that is running the cluster service.
|
||||
machineID string
|
||||
// initialised is closed when the machine is configured as a member of a cluster.
|
||||
initialised <-chan struct{}
|
||||
// ready is closed when the cluster controller has finished starting all components
|
||||
// and the machine is ready to serve cluster requests.
|
||||
ready <-chan struct{}
|
||||
}
|
||||
|
||||
func NewCluster(store *store.Store, corroAdmin *corrosion.AdminClient) *Cluster {
|
||||
func NewCluster(store *store.Store, corroAdmin *corrosion.AdminClient, initialised, ready <-chan struct{}) *Cluster {
|
||||
return &Cluster{
|
||||
store: store,
|
||||
corroAdmin: corroAdmin,
|
||||
initialised: initialised,
|
||||
ready: ready,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,67 +48,45 @@ func (c *Cluster) UpdateMachineID(mid string) {
|
||||
}
|
||||
|
||||
func (c *Cluster) Init(ctx context.Context, network netip.Prefix) error {
|
||||
initialised, err := c.Initialised(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if initialised {
|
||||
return fmt.Errorf("cluster is already initialised")
|
||||
select {
|
||||
case <-c.initialised:
|
||||
return fmt.Errorf("cluster is already initialised on this machine")
|
||||
default:
|
||||
}
|
||||
|
||||
if err = c.store.Put(ctx, "network", network.String()); err != nil {
|
||||
if err := c.store.Put(ctx, "network", network.String()); err != nil {
|
||||
return fmt.Errorf("put network to store: %w", err)
|
||||
}
|
||||
if err = c.store.Put(ctx, "created_at", time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
if err := c.store.Put(ctx, "created_at", time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("put created_at to store: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cluster) Initialised(ctx context.Context) (bool, error) {
|
||||
var createdAt string
|
||||
if err := c.store.Get(ctx, "created_at", &createdAt); err != nil {
|
||||
if errors.Is(err, store.ErrKeyNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, status.Errorf(codes.Internal, "get created_at from store: %v", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Cluster) checkInitialised(ctx context.Context) error {
|
||||
initialised, err := c.Initialised(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !initialised {
|
||||
return status.Error(codes.FailedPrecondition, "cluster is not initialised")
|
||||
}
|
||||
// checkReady checks if the machine is ready to serve cluster requests (store synced, cluster components started).
|
||||
func (c *Cluster) checkReady() error {
|
||||
select {
|
||||
case <-c.ready:
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cluster) Network(ctx context.Context) (netip.Prefix, error) {
|
||||
if err := c.checkInitialised(ctx); err != nil {
|
||||
return netip.Prefix{}, err
|
||||
default:
|
||||
return status.Error(codes.Unavailable, "machine is not ready to serve cluster requests")
|
||||
}
|
||||
|
||||
var net string
|
||||
if err := c.store.Get(ctx, "network", &net); err != nil {
|
||||
return netip.Prefix{}, status.Errorf(codes.Internal, "get network from store: %v", err)
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(net)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, status.Errorf(codes.Internal, "parse network prefix: %v", err)
|
||||
}
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
// AddMachine adds a machine to the cluster.
|
||||
func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*pb.AddMachineResponse, error) {
|
||||
if err := c.checkInitialised(ctx); err != nil {
|
||||
if err := c.checkReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.AddMachineWithoutReadyCheck(ctx, req)
|
||||
}
|
||||
|
||||
// AddMachineWithoutReadyCheck adds a machine to the cluster without checking if the cluster is ready.
|
||||
// This is used internally during cluster initialisation to add the first machine.
|
||||
func (c *Cluster) AddMachineWithoutReadyCheck(
|
||||
ctx context.Context, req *pb.AddMachineRequest,
|
||||
) (*pb.AddMachineResponse, error) {
|
||||
if req.Network == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "network not set")
|
||||
}
|
||||
@@ -162,7 +147,7 @@ func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*p
|
||||
manageIP = pb.NewIP(network.ManagementIP(req.Network.PublicKey))
|
||||
}
|
||||
// Allocate a subnet for the machine from the cluster network.
|
||||
clusterNetwork, err := c.Network(ctx)
|
||||
clusterNetwork, err := c.network(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "get cluster network: %v", err)
|
||||
}
|
||||
@@ -198,9 +183,21 @@ func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*p
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Cluster) network(ctx context.Context) (netip.Prefix, error) {
|
||||
var net string
|
||||
if err := c.store.Get(ctx, "network", &net); err != nil {
|
||||
return netip.Prefix{}, status.Errorf(codes.Internal, "get network from store: %v", err)
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(net)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, status.Errorf(codes.Internal, "parse network prefix: %v", err)
|
||||
}
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
// UpdateMachine updates machine configuration in the cluster.
|
||||
func (c *Cluster) UpdateMachine(ctx context.Context, req *pb.UpdateMachineRequest) (*pb.UpdateMachineResponse, error) {
|
||||
if err := c.checkInitialised(ctx); err != nil {
|
||||
if err := c.checkReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -262,7 +259,7 @@ func (c *Cluster) UpdateMachine(ctx context.Context, req *pb.UpdateMachineReques
|
||||
updatedMachine.PublicIp = req.PublicIp
|
||||
}
|
||||
}
|
||||
if req.Endpoints != nil {
|
||||
if len(req.Endpoints) > 0 {
|
||||
updatedMachine.Network.Endpoints = req.Endpoints
|
||||
}
|
||||
|
||||
@@ -283,7 +280,7 @@ func (c *Cluster) UpdateMachine(ctx context.Context, req *pb.UpdateMachineReques
|
||||
|
||||
// ListMachines lists all machines in the cluster including their membership states.
|
||||
func (c *Cluster) ListMachines(ctx context.Context, _ *emptypb.Empty) (*pb.ListMachinesResponse, error) {
|
||||
if err := c.checkInitialised(ctx); err != nil {
|
||||
if err := c.checkReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -329,7 +326,7 @@ func (c *Cluster) ListMachines(ctx context.Context, _ *emptypb.Empty) (*pb.ListM
|
||||
|
||||
// RemoveMachine removes a machine from the cluster.
|
||||
func (c *Cluster) RemoveMachine(ctx context.Context, req *pb.RemoveMachineRequest) (*emptypb.Empty, error) {
|
||||
if err := c.checkInitialised(ctx); err != nil {
|
||||
if err := c.checkReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ type uncloudDNSDomain struct {
|
||||
}
|
||||
|
||||
func (c *Cluster) ReserveDomain(ctx context.Context, req *pb.ReserveDomainRequest) (*pb.Domain, error) {
|
||||
if err := c.checkInitialised(ctx); err != nil {
|
||||
if err := c.checkReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (c *Cluster) ReserveDomain(ctx context.Context, req *pb.ReserveDomainReques
|
||||
dnsClient := dns.NewClient()
|
||||
name, token, err := dnsClient.ReserveDomain(req.Endpoint)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
domain := uncloudDNSDomain{
|
||||
@@ -64,7 +64,7 @@ func (c *Cluster) ReserveDomain(ctx context.Context, req *pb.ReserveDomainReques
|
||||
}
|
||||
|
||||
func (c *Cluster) GetDomain(ctx context.Context, _ *emptypb.Empty) (*pb.Domain, error) {
|
||||
if err := c.checkInitialised(ctx); err != nil {
|
||||
if err := c.checkReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func (c *Cluster) storedDomain(ctx context.Context) (uncloudDNSDomain, error) {
|
||||
}
|
||||
|
||||
func (c *Cluster) ReleaseDomain(ctx context.Context, _ *emptypb.Empty) (*pb.Domain, error) {
|
||||
if err := c.checkInitialised(ctx); err != nil {
|
||||
if err := c.checkReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func (c *Cluster) ReleaseDomain(ctx context.Context, _ *emptypb.Empty) (*pb.Doma
|
||||
func (c *Cluster) CreateDomainRecords(
|
||||
ctx context.Context, req *pb.CreateDomainRecordsRequest,
|
||||
) (*pb.CreateDomainRecordsResponse, error) {
|
||||
if err := c.checkInitialised(ctx); err != nil {
|
||||
if err := c.checkReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -3,4 +3,6 @@ package constants
|
||||
const (
|
||||
// MachineAPIPort is the port for the Machine API service on the management WireGuard network.
|
||||
MachineAPIPort = 51000
|
||||
// UnregistryPort is the port for the embedded container registry listening on the machine IP.
|
||||
UnregistryPort = 5000
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
@@ -30,7 +31,7 @@ type DockerService struct {
|
||||
func (s *DockerService) Start(ctx context.Context) error {
|
||||
_, err := s.Client.ContainerInspect(ctx, s.Name)
|
||||
if err != nil {
|
||||
if !client.IsErrNotFound(err) {
|
||||
if !errdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("inspect container %q: %w", s.Name, err)
|
||||
}
|
||||
if err = s.startNewContainer(ctx); err != nil {
|
||||
@@ -114,7 +115,7 @@ func (s *DockerService) hostConfig() *container.HostConfig {
|
||||
func (s *DockerService) startNewContainer(ctx context.Context) error {
|
||||
_, err := s.Client.ContainerCreate(ctx, s.containerConfig(), s.hostConfig(), nil, nil, s.Name)
|
||||
if err != nil {
|
||||
if !client.IsErrNotFound(err) {
|
||||
if !errdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("create container: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,11 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
||||
newServiceIPs[ctr.ServiceName()] = append(newServiceIPs[ctr.ServiceName()], ip)
|
||||
// Also add the service ID as a valid lookup.
|
||||
newServiceIPs[ctr.ServiceID()] = append(newServiceIPs[ctr.ServiceID()], ip)
|
||||
|
||||
// Add <machine-id>.m.<service-name> as a lookup
|
||||
serviceNameWithMachineID := record.MachineID + ".m." + ctr.ServiceName()
|
||||
newServiceIPs[serviceNameWithMachineID] = append(newServiceIPs[serviceNameWithMachineID], ip)
|
||||
|
||||
containersCount++
|
||||
}
|
||||
|
||||
@@ -99,7 +104,7 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
||||
r.serviceIPs = newServiceIPs
|
||||
r.mu.Unlock()
|
||||
|
||||
r.log.Debug("DNS records updated.", "services", len(newServiceIPs)/2, "containers", containersCount)
|
||||
r.log.Debug("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
|
||||
}
|
||||
|
||||
// Resolve returns IP addresses of the service containers.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -40,6 +41,7 @@ type Resolver interface {
|
||||
// to upstream DNS servers.
|
||||
type Server struct {
|
||||
listenAddr netip.Addr
|
||||
localSubnet netip.Prefix
|
||||
resolver Resolver
|
||||
upstreamServers []netip.AddrPort
|
||||
|
||||
@@ -53,7 +55,7 @@ type Server struct {
|
||||
// NewServer creates a new DNS server with the given configuration.
|
||||
// If upstreams is nil, nameservers from /etc/resolv.conf will be used. An empty upstreams list means to only resolve
|
||||
// internal DNS queries and not forward any external queries.
|
||||
func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) {
|
||||
func NewServer(listenAddr netip.Addr, localSubnet netip.Prefix, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) {
|
||||
if !listenAddr.IsValid() {
|
||||
return nil, fmt.Errorf("invalid listen address: %s", listenAddr)
|
||||
}
|
||||
@@ -87,6 +89,7 @@ func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrP
|
||||
|
||||
return &Server{
|
||||
listenAddr: listenAddr,
|
||||
localSubnet: localSubnet,
|
||||
resolver: resolver,
|
||||
upstreamServers: upstreams,
|
||||
forwardSemaphore: make(chan struct{}, maxConcurrentForwards),
|
||||
@@ -287,7 +290,7 @@ func (s *Server) forwardRequest(req *dns.Msg, proto string) (*dns.Msg, error) {
|
||||
// handleAQuery processes an A query for the internal domain and returns A records for the requested name.
|
||||
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
|
||||
func (s *Server) handleAQuery(name string) []dns.RR {
|
||||
serviceName := trimInternalDomain(name)
|
||||
serviceName, mode := extractModeFromDomain(trimInternalDomain(name))
|
||||
ips := s.resolver.Resolve(serviceName)
|
||||
if len(ips) == 0 {
|
||||
s.log.Debug("Failed to resolve service name.", "service", serviceName)
|
||||
@@ -296,10 +299,28 @@ func (s *Server) handleAQuery(name string) []dns.RR {
|
||||
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips)
|
||||
|
||||
if len(ips) > 1 {
|
||||
// TODO: sort by proximity to the requesting container/machine. For now, just shuffle the IPs.
|
||||
// Shuffle the IPs to approximate round-robin.
|
||||
// We want to do this as a baseline for "nearest" mode, as well.
|
||||
rand.Shuffle(len(ips), func(i, j int) {
|
||||
ips[i], ips[j] = ips[j], ips[i]
|
||||
})
|
||||
|
||||
// Default (mode == "") currently behaves the same as round-robin,
|
||||
// and nothing additional to do for round-robin (mode == "rr").
|
||||
|
||||
if mode == "nearest" {
|
||||
// Sort IPs on local subnet to the top.
|
||||
slices.SortFunc(ips, func(a, b netip.Addr) int {
|
||||
aIsLocal := s.localSubnet.Contains(a)
|
||||
bIsLocal := s.localSubnet.Contains(b)
|
||||
if aIsLocal && !bIsLocal {
|
||||
return -1
|
||||
} else if bIsLocal && !aIsLocal {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create A records for each IP.
|
||||
@@ -345,3 +366,13 @@ func trimInternalDomain(name string) string {
|
||||
|
||||
return strings.TrimSuffix(name, "."+InternalDomain)
|
||||
}
|
||||
|
||||
func extractModeFromDomain(name string) (string, string) {
|
||||
modes := []string{"nearest", "rr"}
|
||||
for _, mode := range modes {
|
||||
if cut, found := strings.CutPrefix(name, mode+"."); found {
|
||||
return cut, mode
|
||||
}
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
regtypes "github.com/google/go-containerregistry/pkg/v1/types"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/psviderski/uncloud/internal/docker"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"google.golang.org/grpc"
|
||||
@@ -24,16 +24,18 @@ import (
|
||||
)
|
||||
|
||||
// Client is a gRPC client for the Docker service that provides a similar interface to the Docker HTTP client.
|
||||
// TODO: it doesn't seem there is much value in having this intermediate Docker client.
|
||||
// Consider merging it into the main pkg/client.
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
grpcClient pb.DockerClient
|
||||
GRPCClient pb.DockerClient
|
||||
}
|
||||
|
||||
// NewClient creates a new Docker gRPC client with the provided gRPC connection.
|
||||
func NewClient(conn *grpc.ClientConn) *Client {
|
||||
return &Client{
|
||||
conn: conn,
|
||||
grpcClient: pb.NewDockerClient(conn),
|
||||
GRPCClient: pb.NewDockerClient(conn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +72,7 @@ func (c *Client) CreateContainer(
|
||||
return resp, fmt.Errorf("marshal platform: %w", err)
|
||||
}
|
||||
|
||||
grpcResp, err := c.grpcClient.CreateContainer(ctx, &pb.CreateContainerRequest{
|
||||
grpcResp, err := c.GRPCClient.CreateContainer(ctx, &pb.CreateContainerRequest{
|
||||
Config: configBytes,
|
||||
HostConfig: hostConfigBytes,
|
||||
NetworkConfig: networkingConfigBytes,
|
||||
@@ -91,10 +93,10 @@ func (c *Client) CreateContainer(
|
||||
}
|
||||
|
||||
// InspectContainer returns the container information for the given container ID.
|
||||
func (c *Client) InspectContainer(ctx context.Context, id string) (types.ContainerJSON, error) {
|
||||
var resp types.ContainerJSON
|
||||
func (c *Client) InspectContainer(ctx context.Context, id string) (container.InspectResponse, error) {
|
||||
var resp container.InspectResponse
|
||||
|
||||
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||
grpcResp, err := c.GRPCClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
@@ -115,7 +117,7 @@ func (c *Client) StartContainer(ctx context.Context, id string, opts container.S
|
||||
return fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.grpcClient.StartContainer(ctx, &pb.StartContainerRequest{
|
||||
_, err = c.GRPCClient.StartContainer(ctx, &pb.StartContainerRequest{
|
||||
Id: id,
|
||||
Options: optsBytes,
|
||||
})
|
||||
@@ -134,7 +136,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
|
||||
return fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.grpcClient.StopContainer(ctx, &pb.StopContainerRequest{
|
||||
_, err = c.GRPCClient.StopContainer(ctx, &pb.StopContainerRequest{
|
||||
Id: id,
|
||||
Options: optsBytes,
|
||||
})
|
||||
@@ -148,7 +150,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
|
||||
|
||||
type MachineContainers struct {
|
||||
Metadata *pb.Metadata
|
||||
Containers []types.ContainerJSON
|
||||
Containers []container.InspectResponse
|
||||
}
|
||||
|
||||
func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions) ([]MachineContainers, error) {
|
||||
@@ -157,7 +159,7 @@ func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions)
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.grpcClient.ListContainers(ctx, &pb.ListContainersRequest{Options: optsBytes})
|
||||
resp, err := c.GRPCClient.ListContainers(ctx, &pb.ListContainersRequest{Options: optsBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -184,7 +186,7 @@ func (c *Client) RemoveContainer(ctx context.Context, id string, opts container.
|
||||
return fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.grpcClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
|
||||
_, err = c.GRPCClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
|
||||
Id: id,
|
||||
Options: optsBytes,
|
||||
})
|
||||
@@ -205,23 +207,18 @@ type PullOptions struct {
|
||||
Platform string
|
||||
}
|
||||
|
||||
type PullImageMessage struct {
|
||||
Message jsonmessage.JSONMessage
|
||||
Err error
|
||||
}
|
||||
|
||||
func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions) (<-chan PullImageMessage, error) {
|
||||
func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions) (<-chan docker.PullPushImageMessage, error) {
|
||||
optsBytes, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
stream, err := c.grpcClient.PullImage(ctx, &pb.PullImageRequest{Image: image, Options: optsBytes})
|
||||
stream, err := c.GRPCClient.PullImage(ctx, &pb.PullImageRequest{Image: image, Options: optsBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan PullImageMessage)
|
||||
ch := make(chan docker.PullPushImageMessage)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
@@ -232,16 +229,16 @@ func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ch <- PullImageMessage{Err: err}
|
||||
ch <- docker.PullPushImageMessage{Err: err}
|
||||
return
|
||||
}
|
||||
|
||||
var jm jsonmessage.JSONMessage
|
||||
if err = json.Unmarshal(msg.Message, &jm); err != nil {
|
||||
ch <- PullImageMessage{Err: fmt.Errorf("unmarshal JSON message: %w", err)}
|
||||
ch <- docker.PullPushImageMessage{Err: fmt.Errorf("unmarshal JSON message: %w", err)}
|
||||
return
|
||||
}
|
||||
ch <- PullImageMessage{Message: jm}
|
||||
ch <- docker.PullPushImageMessage{Message: jm}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -250,7 +247,7 @@ func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions)
|
||||
|
||||
// InspectImage returns the image information for the given image ID. The request may be sent to multiple machines.
|
||||
func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||
resp, err := c.grpcClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
|
||||
resp, err := c.GRPCClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
|
||||
if err != nil {
|
||||
// If the request was sent to only one machine, err is an actual error from the machine.
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
@@ -288,7 +285,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
|
||||
// credentials if necessary. If the response from a machine doesn't contain an error, the api.RemoteImage will either
|
||||
// contain an IndexManifest or an ImageManifest.
|
||||
func (c *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
||||
resp, err := c.grpcClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
|
||||
resp, err := c.GRPCClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -365,7 +362,7 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
|
||||
return vol, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.grpcClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
|
||||
resp, err := c.GRPCClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
|
||||
if err != nil {
|
||||
return vol, err
|
||||
}
|
||||
@@ -377,20 +374,18 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
|
||||
return vol, nil
|
||||
}
|
||||
|
||||
// MachineVolumes represents a volume list response from a machine.
|
||||
type MachineVolumes struct {
|
||||
Metadata *pb.Metadata
|
||||
Response volume.ListResponse
|
||||
}
|
||||
|
||||
// ListVolumes returns a list of all volumes matching the filter.
|
||||
func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]MachineVolumes, error) {
|
||||
optsBytes, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.grpcClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
|
||||
resp, err := c.GRPCClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -412,7 +407,7 @@ func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]Ma
|
||||
|
||||
// RemoveVolume removes a volume with the given ID.
|
||||
func (c *Client) RemoveVolume(ctx context.Context, id string, force bool) error {
|
||||
_, err := c.grpcClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
|
||||
_, err := c.GRPCClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
|
||||
Id: id,
|
||||
Force: force,
|
||||
})
|
||||
@@ -435,7 +430,7 @@ func (c *Client) CreateServiceContainer(
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("marshal service spec: %w", err)
|
||||
}
|
||||
grpcResp, err := c.grpcClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
|
||||
grpcResp, err := c.GRPCClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
|
||||
ServiceId: serviceID,
|
||||
ServiceSpec: specBytes,
|
||||
ContainerName: containerName,
|
||||
@@ -458,7 +453,7 @@ func (c *Client) CreateServiceContainer(
|
||||
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
|
||||
var resp api.ServiceContainer
|
||||
|
||||
grpcResp, err := c.grpcClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||
grpcResp, err := c.GRPCClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
@@ -491,7 +486,7 @@ func (c *Client) ListServiceContainers(
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.grpcClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
|
||||
resp, err := c.GRPCClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
|
||||
ServiceId: serviceNameOrID,
|
||||
Options: optsBytes,
|
||||
})
|
||||
@@ -530,7 +525,7 @@ func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts con
|
||||
return fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.grpcClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
||||
_, err = c.GRPCClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
||||
Id: id,
|
||||
Options: optsBytes,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/moby/term"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ExecConfig contains options for executing a command in a container.
|
||||
type ExecConfig struct {
|
||||
// Container ID to execute the command in.
|
||||
ContainerID string
|
||||
// Exec configuration.
|
||||
Options api.ExecOptions
|
||||
}
|
||||
|
||||
// sendResizeRequest sends a terminal resize request to the exec stream.
|
||||
func sendResizeRequest(stream pb.Docker_ExecContainerClient, size *term.Winsize) error {
|
||||
slog.Debug("sending resize request", "width", size.Width, "height", size.Height)
|
||||
return stream.Send(
|
||||
&pb.ExecContainerRequest{
|
||||
Payload: &pb.ExecContainerRequest_Resize{
|
||||
Resize: &pb.ResizeEvent{
|
||||
Height: uint32(size.Height),
|
||||
Width: uint32(size.Width),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// setupTerminal configures the terminal for interactive TTY sessions.
|
||||
// It checks if stdin is a terminal, sets it to raw mode, and sets up resize handling.
|
||||
// Returns a cleanup function to restore terminal state, or an error.
|
||||
func setupTerminal(ctx context.Context, stream pb.Docker_ExecContainerClient) (func(), error) {
|
||||
inFd, isTerminal := term.GetFdInfo(os.Stdin)
|
||||
if !isTerminal {
|
||||
return nil, fmt.Errorf("stdin is not a terminal")
|
||||
}
|
||||
|
||||
// Set terminal to raw mode
|
||||
oldState, err := term.SetRawTerminal(inFd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set raw terminal: %w", err)
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
restoreFunc := func() {
|
||||
_ = term.RestoreTerminal(inFd, oldState)
|
||||
}
|
||||
|
||||
// Set up resize handling
|
||||
if err := handleTerminalResize(ctx, inFd, stream); err != nil {
|
||||
restoreFunc()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return restoreFunc, nil
|
||||
}
|
||||
|
||||
// handleTerminalResize sends initial window size and handles window resize signals for TTY sessions.
|
||||
func handleTerminalResize(ctx context.Context, inFd uintptr, stream pb.Docker_ExecContainerClient) error {
|
||||
// Handle window resize signals
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, unix.SIGWINCH)
|
||||
|
||||
// Send initial window size
|
||||
if size, err := term.GetWinsize(inFd); err == nil {
|
||||
_ = sendResizeRequest(stream, size)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer signal.Stop(sigCh)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-sigCh:
|
||||
size, err := term.GetWinsize(inFd)
|
||||
if err != nil {
|
||||
slog.Debug("get window size", "error", err)
|
||||
continue
|
||||
}
|
||||
if err = sendResizeRequest(stream, size); err != nil {
|
||||
slog.Debug("send resize request", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleClientInputStream reads from stdin and sends data to the remote server.
|
||||
// It also periodically checks for context cancellation to exit gracefully when e.g.
|
||||
// the output stream is closed.
|
||||
func handleClientInputStream(ctx context.Context, stream pb.Docker_ExecContainerClient, stdin io.Reader) error {
|
||||
slog.Debug("Input goroutine started")
|
||||
defer slog.Debug("Input goroutine exited")
|
||||
|
||||
defer stream.CloseSend()
|
||||
|
||||
// Channel to receive stdin data
|
||||
stdinCh := make(chan []byte)
|
||||
|
||||
stdinErrCh := make(chan error, 1)
|
||||
|
||||
// Read from stdin in a separate goroutine
|
||||
// Note: this goroutine may continue blocking on Read even after we exit from the function,
|
||||
// but that's OK - it will eventually unblock when data arrives or stdin closes.
|
||||
go func() {
|
||||
buf := make([]byte, 32*1024) // 32KB buffer
|
||||
for {
|
||||
n, err := stdin.Read(buf)
|
||||
if n > 0 {
|
||||
data := make([]byte, n)
|
||||
copy(data, buf[:n])
|
||||
select {
|
||||
case stdinCh <- data:
|
||||
case <-ctx.Done():
|
||||
slog.Debug("stdin reader exiting due to context done")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
slog.Debug("stdin reader: EOF received")
|
||||
} else {
|
||||
slog.Debug("stdin reader error", "error", err)
|
||||
}
|
||||
stdinErrCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Send stdin data to the server or exit when context is cancelled
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case data := <-stdinCh:
|
||||
if err := stream.Send(&pb.ExecContainerRequest{
|
||||
Payload: &pb.ExecContainerRequest_Stdin{Stdin: data},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("send stdin: %w", err)
|
||||
}
|
||||
case err := <-stdinErrCh:
|
||||
if err != io.EOF {
|
||||
return fmt.Errorf("read stdin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleClientOutputStream receives output from the exec stream and writes to stdout/stderr.
|
||||
// It also captures the exit code and signals completion via context cancellation.
|
||||
func handleClientOutputStream(ctx context.Context, stream pb.Docker_ExecContainerClient, stdout, stderr io.Writer, exitCode *int) error {
|
||||
slog.Debug("Output goroutine started")
|
||||
defer slog.Debug("Output goroutine exited")
|
||||
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
slog.Debug("output stream: EOF received")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("receive from stream: %w", err)
|
||||
}
|
||||
|
||||
switch payload := resp.Payload.(type) {
|
||||
case *pb.ExecContainerResponse_ExecId:
|
||||
// This is sent first; we already processed it earlier, so just ignore duplicates.
|
||||
case *pb.ExecContainerResponse_Stdout:
|
||||
if _, err := stdout.Write(payload.Stdout); err != nil {
|
||||
return fmt.Errorf("write stdout: %w", err)
|
||||
}
|
||||
case *pb.ExecContainerResponse_Stderr:
|
||||
if _, err := stderr.Write(payload.Stderr); err != nil {
|
||||
return fmt.Errorf("write stderr: %w", err)
|
||||
}
|
||||
case *pb.ExecContainerResponse_ExitCode:
|
||||
slog.Debug("received exit code", "code", payload.ExitCode)
|
||||
*exitCode = int(payload.ExitCode)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExecContainer executes a command in a running container with bidirectional streaming.
|
||||
// TODO: This can be merged with pkg/client as it's an unnecessary logic split.
|
||||
func (c *Client) ExecContainer(ctx context.Context, opts ExecConfig) (exitCode int, err error) {
|
||||
// Note: In non-interactive mode (without TTY), signals like SIGTERM/SIGINT will terminate the client
|
||||
// process without being forwarded to the remote container process. We could catch and forward these
|
||||
// signals (useful for long-running commands), but for example "kubectl exec" doesn't do this either (as of November 2025),
|
||||
// so we keep it simple for now.
|
||||
|
||||
slog.Debug("starting ExecContainer", "containerID", opts.ContainerID, "options", opts.Options)
|
||||
|
||||
// Initialize exit code to non-zero in case we have to return early
|
||||
exitCode = 1
|
||||
|
||||
// Set up I/O streams - use custom streams if provided, otherwise default to os.Stdin/Stdout/Stderr
|
||||
stdin := io.Reader(os.Stdin)
|
||||
stdout := io.Writer(os.Stdout)
|
||||
stderr := io.Writer(os.Stderr)
|
||||
|
||||
if opts.Options.Stdin != nil {
|
||||
stdin = opts.Options.Stdin
|
||||
}
|
||||
if opts.Options.Stdout != nil {
|
||||
stdout = opts.Options.Stdout
|
||||
}
|
||||
if opts.Options.Stderr != nil {
|
||||
stderr = opts.Options.Stderr
|
||||
}
|
||||
|
||||
// Marshal the exec config
|
||||
configBytes, err := json.Marshal(opts.Options)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("marshal exec config: %w", err)
|
||||
}
|
||||
|
||||
// Create the bidirectional stream
|
||||
stream, err := c.GRPCClient.ExecContainer(ctx)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("create exec stream: %w", err)
|
||||
}
|
||||
|
||||
// Send the initial configuration
|
||||
if err := stream.Send(&pb.ExecContainerRequest{
|
||||
Payload: &pb.ExecContainerRequest_Config{
|
||||
Config: &pb.ExecConfig{
|
||||
ContainerId: opts.ContainerID,
|
||||
Options: configBytes,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return -1, fmt.Errorf("send exec config: %w", err)
|
||||
}
|
||||
|
||||
// Receive the exec ID
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("receive exec ID: %w", err)
|
||||
}
|
||||
execID := resp.GetExecId()
|
||||
if execID == "" {
|
||||
return -1, fmt.Errorf("expected exec ID in first response")
|
||||
}
|
||||
|
||||
errGroup, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
// Create cancellable context for goroutine coordination
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Handle terminal setup for interactive sessions
|
||||
if opts.Options.AttachStdin && opts.Options.Tty {
|
||||
restoreTerminal, err := setupTerminal(ctx, stream)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("setup terminal: %w", err)
|
||||
}
|
||||
if restoreTerminal != nil {
|
||||
defer restoreTerminal()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stdin stream if needed
|
||||
if opts.Options.AttachStdin {
|
||||
errGroup.Go(func() error {
|
||||
return handleClientInputStream(ctx, stream, stdin)
|
||||
})
|
||||
} else {
|
||||
// Close send direction immediately if not attaching stdin
|
||||
stream.CloseSend()
|
||||
}
|
||||
|
||||
// Handle output streams (stdout/stderr)
|
||||
errGroup.Go(func() error {
|
||||
defer cancel()
|
||||
return handleClientOutputStream(ctx, stream, stdout, stderr, &exitCode)
|
||||
})
|
||||
|
||||
err = errGroup.Wait()
|
||||
|
||||
if err == nil && opts.Options.Detach {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return exitCode, err
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"net/netip"
|
||||
"strconv"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
dockercontainer "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dnetwork "github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/libnetwork/iptables"
|
||||
"github.com/psviderski/uncloud/internal/machine/dns"
|
||||
"github.com/psviderski/uncloud/internal/machine/firewall"
|
||||
@@ -27,7 +27,7 @@ func (c *Controller) EnsureUncloudNetwork(ctx context.Context, subnet netip.Pref
|
||||
needsCreation := false
|
||||
nw, err := c.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
||||
if err != nil {
|
||||
if !client.IsErrNotFound(err) {
|
||||
if !errdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
||||
}
|
||||
needsCreation = true
|
||||
@@ -187,7 +187,7 @@ func (c *Controller) Cleanup() error {
|
||||
|
||||
for _, ctr := range containers {
|
||||
err = c.client.ContainerStop(ctx, ctr.ID, dockercontainer.StopOptions{})
|
||||
if err != nil && !client.IsErrNotFound(err) {
|
||||
if err != nil && !errdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("stop container '%s': %w", ctr.ID, err))
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ func (c *Controller) Cleanup() error {
|
||||
})
|
||||
if err == nil {
|
||||
removed++
|
||||
} else if !client.IsErrNotFound(err) {
|
||||
} else if !errdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("remove container '%s': %w", ctr.ID, err))
|
||||
}
|
||||
}
|
||||
@@ -223,10 +223,10 @@ func (c *Controller) Cleanup() error {
|
||||
|
||||
if err = c.client.NetworkRemove(ctx, NetworkName); err == nil {
|
||||
slog.Info("Docker network removed.", "name", NetworkName)
|
||||
} else if !client.IsErrNotFound(err) {
|
||||
} else if !errdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("remove Docker network '%s': %w", NetworkName, err))
|
||||
}
|
||||
} else if !client.IsErrNotFound(err) {
|
||||
} else if !errdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/distribution/reference"
|
||||
dockercommand "github.com/docker/cli/cli/command"
|
||||
dockerconfig "github.com/docker/cli/cli/config"
|
||||
@@ -25,7 +31,9 @@ import (
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
@@ -40,6 +48,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
@@ -53,41 +62,35 @@ type Server struct {
|
||||
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
||||
// address if the address is unknown (e.g. when the machine is not initialised yet).
|
||||
internalDNSIP func() netip.Addr
|
||||
// machineID is a function that returns the machine ID. It may return an empty string if the machine
|
||||
// is not initialised yet.
|
||||
machineID func() string
|
||||
// networkReady is a function that returns true if the Docker network is ready for containers.
|
||||
networkReady func() bool
|
||||
// waitForNetworkReady is a function that waits for the Docker network to be ready for containers.
|
||||
waitForNetworkReady func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// ServerOption configures the Docker server.
|
||||
type ServerOption func(*Server)
|
||||
|
||||
// WithNetworkReady sets the network readiness check function.
|
||||
func WithNetworkReady(networkReady func() bool) ServerOption {
|
||||
return func(s *Server) {
|
||||
s.networkReady = networkReady
|
||||
}
|
||||
}
|
||||
|
||||
// WithWaitForNetworkReady sets the network readiness wait function.
|
||||
func WithWaitForNetworkReady(waitForNetworkReady func(ctx context.Context) error) ServerOption {
|
||||
return func(s *Server) {
|
||||
s.waitForNetworkReady = waitForNetworkReady
|
||||
}
|
||||
type ServerOptions struct {
|
||||
// TODO: verify if we still need the network readiness checks as the cluster controller ensures the network
|
||||
// is ready before starting the network API server. It may still be needed when communicating with the local
|
||||
// API server but in this case we should probably fail until the cluster is initialised.
|
||||
NetworkReady func() bool
|
||||
WaitForNetworkReady func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// NewServer creates a new Docker gRPC server with the provided Docker service.
|
||||
func NewServer(service *Service, db *sqlx.DB, internalDNSIP func() netip.Addr, opts ...ServerOption) *Server {
|
||||
func NewServer(service *Service, db *sqlx.DB, internalDNSIP func() netip.Addr, machineID func() string, opts ServerOptions) *Server {
|
||||
s := &Server{
|
||||
client: service.Client,
|
||||
service: service,
|
||||
db: db,
|
||||
internalDNSIP: internalDNSIP,
|
||||
machineID: machineID,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
s.networkReady = opts.NetworkReady
|
||||
s.waitForNetworkReady = opts.WaitForNetworkReady
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -115,10 +118,10 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
|
||||
|
||||
resp, err := s.client.ContainerCreate(ctx, &config, &hostConfig, &networkConfig, &platform, req.Name)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
@@ -133,10 +136,10 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
|
||||
func (s *Server) InspectContainer(ctx context.Context, req *pb.InspectContainerRequest) (*pb.InspectContainerResponse, error) {
|
||||
resp, err := s.client.ContainerInspect(ctx, req.Id)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
@@ -166,10 +169,10 @@ func (s *Server) StartContainer(ctx context.Context, req *pb.StartContainerReque
|
||||
}
|
||||
|
||||
if err := s.client.ContainerStart(ctx, req.Id, opts); err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
@@ -185,10 +188,10 @@ func (s *Server) StopContainer(ctx context.Context, req *pb.StopContainerRequest
|
||||
}
|
||||
|
||||
if err := s.client.ContainerStop(ctx, req.Id, opts); err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
@@ -218,13 +221,13 @@ func (s *Server) ListContainers(ctx context.Context, req *pb.ListContainersReque
|
||||
|
||||
containerSummaries, err := s.client.ContainerList(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
containers := make([]types.ContainerJSON, 0, len(containerSummaries))
|
||||
containers := make([]container.InspectResponse, 0, len(containerSummaries))
|
||||
for _, cs := range containerSummaries {
|
||||
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
// The listed container may have been removed while we were inspecting other containers.
|
||||
continue
|
||||
}
|
||||
@@ -257,10 +260,10 @@ func (s *Server) RemoveContainer(ctx context.Context, req *pb.RemoveContainerReq
|
||||
}
|
||||
|
||||
if err := s.client.ContainerRemove(ctx, req.Id, opts); err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
@@ -286,7 +289,7 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
|
||||
|
||||
respBody, err := s.client.ImagePull(ctx, req.Image, opts)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, err.Error())
|
||||
return status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
@@ -317,19 +320,19 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
|
||||
case err = <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return status.Errorf(codes.Canceled, ctx.Err().Error())
|
||||
return status.Error(codes.Canceled, ctx.Err().Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InspectImage returns the image information for the given image ID.
|
||||
func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest) (*pb.InspectImageResponse, error) {
|
||||
resp, _, err := s.client.ImageInspectWithRaw(ctx, req.Id)
|
||||
resp, err := s.client.ImageInspect(ctx, req.Id)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
@@ -385,6 +388,49 @@ func (s *Server) InspectRemoteImage(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListImages returns a list of all images matching the filter and indicates whether Docker is using the containerd
|
||||
// image store.
|
||||
func (s *Server) ListImages(ctx context.Context, req *pb.ListImagesRequest) (*pb.ListImagesResponse, error) {
|
||||
var opts image.ListOptions
|
||||
if len(req.Options) > 0 {
|
||||
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
||||
}
|
||||
|
||||
// Handle filters separately because they implement custom JSON unmarshalling.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(req.Options, &raw); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
|
||||
}
|
||||
|
||||
if filtersBytes, ok := raw["Filters"]; ok {
|
||||
args, err := filters.FromJSON(string(filtersBytes))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
||||
}
|
||||
opts.Filters = args
|
||||
}
|
||||
}
|
||||
|
||||
images, err := s.service.ListImages(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
machineImages := pb.MachineImages{
|
||||
ContainerdStore: images.ContainerdStore,
|
||||
}
|
||||
if len(images.Images) > 0 {
|
||||
if machineImages.Images, err = json.Marshal(images.Images); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "marshal Docker images: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.ListImagesResponse{
|
||||
Messages: []*pb.MachineImages{&machineImages},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateVolume creates a new volume with the given options.
|
||||
func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest) (*pb.CreateVolumeResponse, error) {
|
||||
var opts volume.CreateOptions
|
||||
@@ -456,7 +502,7 @@ func (s *Server) ListVolumes(ctx context.Context, req *pb.ListVolumesRequest) (*
|
||||
// RemoveVolume removes a volume with the given ID.
|
||||
func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest) (*emptypb.Empty, error) {
|
||||
if err := s.client.VolumeRemove(ctx, req.Id, req.Force); err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
@@ -492,9 +538,21 @@ func (s *Server) CreateServiceContainer(
|
||||
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
|
||||
}
|
||||
|
||||
envVars := maps.Clone(spec.Container.Env)
|
||||
if envVars == nil {
|
||||
envVars = make(api.EnvVars)
|
||||
}
|
||||
|
||||
// Inject the machine ID if available
|
||||
if s.machineID != nil {
|
||||
if machineID := s.machineID(); machineID != "" {
|
||||
envVars["UNCLOUD_MACHINE_ID"] = machineID
|
||||
}
|
||||
}
|
||||
|
||||
config := &container.Config{
|
||||
Cmd: spec.Container.Command,
|
||||
Env: spec.Container.Env.ToSlice(),
|
||||
Env: envVars.ToSlice(),
|
||||
Entrypoint: spec.Container.Entrypoint,
|
||||
Hostname: containerName,
|
||||
Image: spec.Container.Image,
|
||||
@@ -509,6 +567,22 @@ func (s *Server) CreateServiceContainer(
|
||||
if spec.Mode == "" {
|
||||
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
|
||||
}
|
||||
if hc := spec.Container.Healthcheck; hc != nil {
|
||||
if hc.Disable {
|
||||
config.Healthcheck = &container.HealthConfig{
|
||||
Test: []string{"NONE"},
|
||||
}
|
||||
} else {
|
||||
config.Healthcheck = &container.HealthConfig{
|
||||
Test: hc.Test,
|
||||
Interval: hc.Interval,
|
||||
Timeout: hc.Timeout,
|
||||
StartPeriod: hc.StartPeriod,
|
||||
StartInterval: hc.StartInterval,
|
||||
Retries: int(hc.Retries),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB.
|
||||
var err error
|
||||
@@ -548,6 +622,8 @@ func (s *Server) CreateServiceContainer(
|
||||
}
|
||||
}
|
||||
hostConfig := &container.HostConfig{
|
||||
CapAdd: spec.Container.CapAdd,
|
||||
CapDrop: spec.Container.CapDrop,
|
||||
Binds: spec.Container.Volumes,
|
||||
Init: spec.Container.Init,
|
||||
Mounts: mounts,
|
||||
@@ -557,12 +633,16 @@ func (s *Server) CreateServiceContainer(
|
||||
NanoCPUs: spec.Container.Resources.CPU,
|
||||
Memory: spec.Container.Resources.Memory,
|
||||
MemoryReservation: spec.Container.Resources.MemoryReservation,
|
||||
Devices: toDockerDevices(spec.Container.Resources.Devices),
|
||||
DeviceRequests: spec.Container.Resources.DeviceReservations,
|
||||
Ulimits: toDockerUlimits(spec.Container.Resources.Ulimits),
|
||||
},
|
||||
// Restart service containers if they exit or a machine restarts unless they are explicitly stopped.
|
||||
// For one-off containers and batch jobs we plan to use a different service type/mode.
|
||||
RestartPolicy: container.RestartPolicy{
|
||||
Name: container.RestartPolicyUnlessStopped,
|
||||
},
|
||||
Sysctls: spec.Container.Sysctls,
|
||||
}
|
||||
|
||||
// Configure the container to use the internal DNS server if it's available.
|
||||
@@ -590,12 +670,19 @@ func (s *Server) CreateServiceContainer(
|
||||
|
||||
resp, err := s.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, containerName)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
// Inject configs into the created container
|
||||
if err = s.injectConfigs(ctx, resp.ID, spec.Configs, spec.Container.ConfigMounts); err != nil {
|
||||
// Remove the container if config injection fails
|
||||
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
|
||||
return nil, status.Errorf(codes.Internal, "inject configs: %v", err)
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
||||
@@ -672,6 +759,121 @@ func ToDockerMounts(volumes []api.VolumeSpec, mounts []api.VolumeMount) ([]mount
|
||||
return dockerMounts, nil
|
||||
}
|
||||
|
||||
// injectConfigs writes config content directly into the container.
|
||||
// It processes ConfigSpecs and ConfigMounts to mount configuration content into the container filesystem.
|
||||
func (s *Server) injectConfigs(ctx context.Context, containerID string, configs []api.ConfigSpec, mounts []api.ConfigMount) error {
|
||||
if len(configs) == 0 || len(mounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := api.ValidateConfigsAndMounts(configs, mounts); err != nil {
|
||||
return fmt.Errorf("validate configs and mounts: %w", err)
|
||||
}
|
||||
|
||||
// Create a map of config name to config spec for quick lookup
|
||||
configMap := make(map[string]api.ConfigSpec)
|
||||
for _, config := range configs {
|
||||
configMap[config.Name] = config
|
||||
}
|
||||
|
||||
// Process each config mount
|
||||
for _, m := range mounts {
|
||||
config, exists := configMap[m.ConfigName]
|
||||
if !exists {
|
||||
return fmt.Errorf("config mount references a config that doesn't exist: '%s'", m.ConfigName)
|
||||
}
|
||||
|
||||
// Determine target path in container
|
||||
targetPath := m.ContainerPath
|
||||
if targetPath == "" {
|
||||
// This is the default from the Compose spec
|
||||
targetPath = filepath.Join("/", m.ConfigName)
|
||||
}
|
||||
|
||||
// Determine file mode
|
||||
fileMode := os.FileMode(0o444) // Default permissions
|
||||
if m.Mode != nil {
|
||||
fileMode = *m.Mode
|
||||
}
|
||||
|
||||
uid, err := m.GetNumericUid()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Uid: %w", err)
|
||||
}
|
||||
|
||||
gid, err := m.GetNumericGid()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Gid: %w", err)
|
||||
}
|
||||
|
||||
// Copy the config content directly into the container
|
||||
if err := s.copyContentToContainer(
|
||||
ctx, containerID, config.Content, targetPath, uid, gid, fileMode,
|
||||
); err != nil {
|
||||
return fmt.Errorf("copy config file '%s' to container: %w", config.Name, err)
|
||||
}
|
||||
|
||||
slog.Debug("Injected config into container",
|
||||
"config", config.Name,
|
||||
"container", containerID[:12],
|
||||
"target", targetPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyContentToContainer copies content directly to a file in the container using Docker's CopyToContainer API.
|
||||
// It will create any intermediate directories in the target path that don't exist.
|
||||
func (s *Server) copyContentToContainer(ctx context.Context, containerID string, content []byte, targetPath string, uid *uint64, gid *uint64, fileMode os.FileMode) error {
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
|
||||
// Trim leading slash(es) to avoid double slashes in the tar path
|
||||
tarPath := strings.TrimPrefix(targetPath, "/")
|
||||
|
||||
// Create tar header with full path
|
||||
header := &tar.Header{
|
||||
Name: tarPath,
|
||||
Size: int64(len(content)),
|
||||
Mode: int64(fileMode),
|
||||
ModTime: time.Now(),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
// Set ownership if specified
|
||||
if uid != nil {
|
||||
header.Uid = int(*uid)
|
||||
}
|
||||
if gid != nil {
|
||||
header.Gid = int(*gid)
|
||||
}
|
||||
|
||||
// Write header and content to tar archive
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("write tar header: %w", err)
|
||||
}
|
||||
if _, err := tw.Write(content); err != nil {
|
||||
return fmt.Errorf("write content to tar: %w", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return fmt.Errorf("close tar writer: %w", err)
|
||||
}
|
||||
|
||||
// Always extract to root. The tar archive contains the full path, so tar will
|
||||
// automatically create any intermediate directories that don't exist in the container.
|
||||
if err := s.client.CopyToContainer(
|
||||
ctx,
|
||||
containerID,
|
||||
"/",
|
||||
&buf,
|
||||
container.CopyToContainerOptions{CopyUIDGID: true},
|
||||
); err != nil {
|
||||
return fmt.Errorf("copy to container: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
||||
if opts == nil {
|
||||
return nil
|
||||
@@ -694,6 +896,40 @@ func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
||||
return dockerOpts
|
||||
}
|
||||
|
||||
func toDockerUlimits(ulimits map[string]api.Ulimit) []*units.Ulimit {
|
||||
if len(ulimits) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dockerUlimits := make([]*units.Ulimit, 0, len(ulimits))
|
||||
for name, u := range ulimits {
|
||||
dockerUlimits = append(dockerUlimits, &units.Ulimit{
|
||||
Name: name,
|
||||
Soft: u.Soft,
|
||||
Hard: u.Hard,
|
||||
})
|
||||
}
|
||||
|
||||
return dockerUlimits
|
||||
}
|
||||
|
||||
func toDockerDevices(devices []api.DeviceMapping) []container.DeviceMapping {
|
||||
if len(devices) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dockerDevices := make([]container.DeviceMapping, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
dockerDevices = append(dockerDevices, container.DeviceMapping{
|
||||
PathOnHost: d.HostPath,
|
||||
PathInContainer: d.ContainerPath,
|
||||
CgroupPermissions: d.CgroupPermissions,
|
||||
})
|
||||
}
|
||||
|
||||
return dockerDevices
|
||||
}
|
||||
|
||||
// verifyDockerVolumesExist checks if the Docker named volumes referenced in the mounts exist on the machine.
|
||||
func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mount) error {
|
||||
for _, m := range mounts {
|
||||
@@ -703,7 +939,7 @@ func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mo
|
||||
|
||||
// TODO: non-local volume drivers should likely be handled differently (needs proper investigation).
|
||||
if _, err := s.client.VolumeInspect(ctx, m.Source); err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return status.Errorf(codes.NotFound, "volume '%s' not found", m.Source)
|
||||
}
|
||||
return status.Errorf(codes.Internal, "inspect volume '%s': %v", m.Source, err.Error())
|
||||
@@ -723,10 +959,10 @@ func (s *Server) InspectServiceContainer(
|
||||
) (*pb.ServiceContainer, error) {
|
||||
serviceCtr, err := s.service.InspectServiceContainer(ctx, req.Id)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return nil, status.Errorf(codes.NotFound, err.Error())
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, err.Error())
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
ctrBytes, err := json.Marshal(serviceCtr.Container)
|
||||
@@ -813,7 +1049,7 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
|
||||
if !fullDockerIDRegex.MatchString(req.Id) {
|
||||
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
@@ -834,3 +1070,349 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// logsHeartbeatInterval is the interval at which heartbeat entries are sent when there are no logs to stream.
|
||||
const logsHeartbeatInterval = 200 * time.Millisecond
|
||||
|
||||
// ContainerLogs streams logs from a container.
|
||||
func (s *Server) ContainerLogs(
|
||||
req *pb.ContainerLogsRequest, stream grpc.ServerStreamingServer[pb.ContainerLogEntry],
|
||||
) error {
|
||||
// Stream context is cancelled when the client has disconnected or the stream has ended.
|
||||
ctx := stream.Context()
|
||||
|
||||
opts := ContainerLogsOptions{
|
||||
ContainerID: req.ContainerId,
|
||||
Follow: req.Follow,
|
||||
Tail: int(req.Tail),
|
||||
Since: req.Since,
|
||||
Until: req.Until,
|
||||
}
|
||||
|
||||
logsCh, err := s.service.ContainerLogs(ctx, opts)
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return status.Errorf(codes.Internal, "get container logs: %v", err)
|
||||
}
|
||||
|
||||
log := slog.With("container_id", req.ContainerId, "stream_id", fmt.Sprintf("%p", stream)[2:])
|
||||
log.Debug("Starting container logs streaming.",
|
||||
"follow", req.Follow, "tail", req.Tail, "since", req.Since, "until", req.Until)
|
||||
|
||||
// Heartbeats are needed only when following logs to let the client know when there are no new log entries
|
||||
// to allow it to advance the watermark of last received log timestamp.
|
||||
var heartbeatCh <-chan time.Time
|
||||
if req.Follow {
|
||||
heartbeatTicker := time.NewTicker(logsHeartbeatInterval)
|
||||
defer heartbeatTicker.Stop()
|
||||
heartbeatCh = heartbeatTicker.C
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
lastSent := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-logsCh:
|
||||
if !ok {
|
||||
// Channel closed, no more log entries.
|
||||
return nil
|
||||
}
|
||||
|
||||
if entry.Err != nil {
|
||||
return status.Error(codes.Internal, entry.Err.Error())
|
||||
}
|
||||
|
||||
pbEntry := &pb.ContainerLogEntry{
|
||||
Stream: api.LogStreamTypeToProto(entry.Stream),
|
||||
Timestamp: timestamppb.New(entry.Timestamp),
|
||||
Message: entry.Message,
|
||||
}
|
||||
if err = stream.Send(pbEntry); err != nil {
|
||||
return status.Errorf(codes.Internal, "send log entry: %v", err)
|
||||
}
|
||||
lastSent = entry.Timestamp
|
||||
|
||||
case now := <-heartbeatCh:
|
||||
// Only send heartbeat if no log entries have been sent since the last heartbeat interval or
|
||||
// if no log entries have been sent at all for at least a heartbeat interval since starting.
|
||||
if now.Sub(lastSent) < logsHeartbeatInterval ||
|
||||
(lastSent.IsZero() && now.Sub(started) < logsHeartbeatInterval) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the timestamp one heartbeat in the past to be conservative. This reduces the chance of sending
|
||||
// a timestamp that is greater than a log entry currently being parsed but not yet sent, which would
|
||||
// cause the client to incorrectly believe it has received all logs up to that point.
|
||||
heartbeat := &pb.ContainerLogEntry{
|
||||
Stream: pb.ContainerLogEntry_HEARTBEAT,
|
||||
Timestamp: timestamppb.New(now.Add(-logsHeartbeatInterval)),
|
||||
}
|
||||
if err = stream.Send(heartbeat); err != nil {
|
||||
return status.Errorf(codes.Internal, "send log stream heartbeat: %v", err)
|
||||
}
|
||||
lastSent = heartbeat.Timestamp.AsTime()
|
||||
log.Debug("Sent log stream heartbeat.", "timestamp", lastSent)
|
||||
|
||||
case <-ctx.Done():
|
||||
return status.Error(codes.Canceled, ctx.Err().Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// receiveExecConfig receives and validates the initial exec configuration from the stream.
|
||||
func (s *Server) receiveExecConfig(stream pb.Docker_ExecContainerServer) (*pb.ExecConfig, api.ExecOptions, error) {
|
||||
req, err := stream.Recv()
|
||||
if err != nil {
|
||||
return nil, api.ExecOptions{}, status.Errorf(codes.InvalidArgument, "receive config: %v", err)
|
||||
}
|
||||
|
||||
execConfig := req.GetConfig()
|
||||
if execConfig == nil {
|
||||
return nil, api.ExecOptions{}, status.Error(codes.InvalidArgument, "first message must contain exec config")
|
||||
}
|
||||
|
||||
// Unmarshal the Uncloud's execOpts
|
||||
var execOpts api.ExecOptions
|
||||
if err := json.Unmarshal(execConfig.Options, &execOpts); err != nil {
|
||||
return nil, api.ExecOptions{}, status.Errorf(codes.InvalidArgument, "unmarshal exec config: %v", err)
|
||||
}
|
||||
|
||||
return execConfig, execOpts, nil
|
||||
}
|
||||
|
||||
// handleServerExecInput reads from the gRPC stream and writes to Docker stdin, handling resize requests.
|
||||
func (s *Server) handleServerExecInput(
|
||||
ctx context.Context,
|
||||
stream pb.Docker_ExecContainerServer,
|
||||
attachConn types.HijackedResponse,
|
||||
execID string,
|
||||
tty bool,
|
||||
) error {
|
||||
slog.Debug("Input goroutine started", "exec_id", execID, "tty", tty)
|
||||
defer slog.Debug("Input goroutine exited", "exec_id", execID)
|
||||
|
||||
defer attachConn.CloseWrite()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Debug("Input goroutine context canceled via context", "exec_id", execID)
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
req, err := stream.Recv()
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
slog.Debug("Input goroutine received EOF", "exec_id", execID)
|
||||
return nil
|
||||
case status.Code(err) == codes.Canceled:
|
||||
// Can be the case when the output goroutine ends and the stream context is canceled.
|
||||
slog.Debug("Input goroutine context canceled", "exec_id", execID)
|
||||
return nil
|
||||
case err == nil:
|
||||
// continue processing
|
||||
default:
|
||||
return fmt.Errorf("receive from stream: %w", err)
|
||||
}
|
||||
|
||||
switch payload := req.Payload.(type) {
|
||||
case *pb.ExecContainerRequest_Stdin:
|
||||
if _, err := attachConn.Conn.Write(payload.Stdin); err != nil {
|
||||
return fmt.Errorf("write to stdin: %w", err)
|
||||
}
|
||||
case *pb.ExecContainerRequest_Resize:
|
||||
if tty {
|
||||
resizeOpts := container.ResizeOptions{
|
||||
Height: uint(payload.Resize.Height),
|
||||
Width: uint(payload.Resize.Width),
|
||||
}
|
||||
if err := s.client.ContainerExecResize(ctx, execID, resizeOpts); err != nil {
|
||||
slog.Warn("Failed to resize TTY", "err", err, "exec_id", execID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleServerExecOutput reads from Docker stdout/stderr and writes to the gRPC stream.
|
||||
func (s *Server) handleServerExecOutput(
|
||||
stream pb.Docker_ExecContainerServer,
|
||||
attachResp types.HijackedResponse,
|
||||
execID string,
|
||||
tty bool,
|
||||
) error {
|
||||
slog.Debug("Output goroutine started", "exec_id", execID, "tty", tty)
|
||||
defer slog.Debug("Output goroutine exited", "exec_id", execID)
|
||||
|
||||
if tty {
|
||||
// In TTY mode, all output is stdout - copy directly to stream
|
||||
stdoutWriter := &grpcStreamWriter{stream: stream, isStderr: false}
|
||||
_, err := io.Copy(stdoutWriter, attachResp.Reader)
|
||||
if err != nil && err != io.EOF {
|
||||
return fmt.Errorf("copy tty output: %w", err)
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
// In non-TTY mode, Docker multiplexes stdout/stderr with headers
|
||||
// Use stdcopy to demultiplex
|
||||
slog.Debug("Starting StdCopy for non-TTY", "exec_id", execID)
|
||||
stdoutWriter := &grpcStreamWriter{stream: stream, isStderr: false}
|
||||
stderrWriter := &grpcStreamWriter{stream: stream, isStderr: true}
|
||||
|
||||
written, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, attachResp.Reader)
|
||||
slog.Debug("StdCopy completed", "exec_id", execID, "bytes", written, "err", err)
|
||||
if err != nil && err != io.EOF {
|
||||
return fmt.Errorf("demultiplex docker output: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// grpcStreamWriter is a writer that sends data to a gRPC stream as stdout or stderr.
|
||||
type grpcStreamWriter struct {
|
||||
stream pb.Docker_ExecContainerServer
|
||||
isStderr bool
|
||||
}
|
||||
|
||||
func (w *grpcStreamWriter) Write(p []byte) (n int, err error) {
|
||||
data := make([]byte, len(p))
|
||||
copy(data, p)
|
||||
|
||||
var resp *pb.ExecContainerResponse
|
||||
if w.isStderr {
|
||||
resp = &pb.ExecContainerResponse{
|
||||
Payload: &pb.ExecContainerResponse_Stderr{Stderr: data},
|
||||
}
|
||||
} else {
|
||||
resp = &pb.ExecContainerResponse{
|
||||
Payload: &pb.ExecContainerResponse_Stdout{Stdout: data},
|
||||
}
|
||||
}
|
||||
|
||||
if err := w.stream.Send(resp); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// ExecContainer executes a command in a running container with bidirectional streaming for stdin/stdout/stderr.
|
||||
func (s *Server) ExecContainer(stream pb.Docker_ExecContainerServer) error {
|
||||
slog.Debug("ExecContainer server-side called")
|
||||
defer slog.Debug("ExecContainer server-side ended")
|
||||
|
||||
ctx := stream.Context()
|
||||
|
||||
// Receive and validate configuration
|
||||
execConfig, execOpts, err := s.receiveExecConfig(stream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert to Docker's ExecOptions
|
||||
dockerExecOpts := container.ExecOptions{
|
||||
Cmd: execOpts.Command,
|
||||
AttachStdin: execOpts.AttachStdin,
|
||||
AttachStdout: execOpts.AttachStdout,
|
||||
AttachStderr: execOpts.AttachStderr,
|
||||
Tty: execOpts.Tty,
|
||||
User: execOpts.User,
|
||||
Privileged: execOpts.Privileged,
|
||||
WorkingDir: execOpts.WorkingDir,
|
||||
Env: execOpts.Env,
|
||||
}
|
||||
|
||||
// Create the exec instance
|
||||
execResp, err := s.client.ContainerExecCreate(ctx, execConfig.ContainerId, dockerExecOpts)
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return status.Errorf(codes.Internal, "create exec: %v", err)
|
||||
}
|
||||
|
||||
// Send the exec ID back to the client
|
||||
if err := stream.Send(&pb.ExecContainerResponse{
|
||||
Payload: &pb.ExecContainerResponse_ExecId{ExecId: execResp.ID},
|
||||
}); err != nil {
|
||||
return status.Errorf(codes.Internal, "send exec ID: %v", err)
|
||||
}
|
||||
slog.Debug("Sent exec ID to the client", "exec_id", execResp.ID)
|
||||
|
||||
// For detached mode, start without attaching
|
||||
if execOpts.Detach {
|
||||
dockerStartOpts := container.ExecStartOptions{
|
||||
Tty: dockerExecOpts.Tty,
|
||||
Detach: true,
|
||||
}
|
||||
if err := s.client.ContainerExecStart(ctx, execResp.ID, dockerStartOpts); err != nil {
|
||||
return status.Errorf(codes.Internal, "start exec: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For attached mode, attach to the exec instance
|
||||
attachOpts := container.ExecAttachOptions{
|
||||
Tty: dockerExecOpts.Tty,
|
||||
}
|
||||
attachConn, err := s.client.ContainerExecAttach(ctx, execResp.ID, attachOpts)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "attach to exec: %v", err)
|
||||
}
|
||||
defer attachConn.Close()
|
||||
|
||||
// Create a cancelable context for the input handler
|
||||
handlerCtx, cancelInput := context.WithCancel(ctx)
|
||||
defer cancelInput()
|
||||
|
||||
// Create a channel to wait for output completion
|
||||
outputDone := make(chan error, 1)
|
||||
|
||||
// Start stdin handler if stdin is attached
|
||||
if dockerExecOpts.AttachStdin {
|
||||
go func() {
|
||||
err := s.handleServerExecInput(handlerCtx, stream, attachConn, execResp.ID, dockerExecOpts.Tty)
|
||||
if err != nil {
|
||||
slog.Warn("Error in exec input handler", "err", err, "exec_id", execResp.ID)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// If not attaching stdin, close the write side immediately
|
||||
attachConn.CloseWrite()
|
||||
}
|
||||
|
||||
// Start output handler
|
||||
// We only wait for this goroutine to complete - it signals when the exec process finishes
|
||||
go func() {
|
||||
outputDone <- s.handleServerExecOutput(stream, attachConn, execResp.ID, dockerExecOpts.Tty)
|
||||
}()
|
||||
|
||||
// Wait for the output goroutine to complete (it signals when done)
|
||||
// We only wait for the output handler goroutine, not for the stdin one.
|
||||
if err := <-outputDone; err != nil {
|
||||
slog.Warn("Error in exec output handler", "err", err, "exec_id", execResp.ID)
|
||||
}
|
||||
// Do a best-effort cancellation of the stdin handler.
|
||||
// We can't guarantee immediate exit because it may be blocked on stream.Recv(), but at
|
||||
// least we want to send a cancel signal explicitly.
|
||||
cancelInput()
|
||||
|
||||
inspectResp, err := s.client.ContainerExecInspect(ctx, execResp.ID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to inspect exec after completion", "err", err, "exec_id", execResp.ID)
|
||||
return status.Errorf(codes.Internal, "inspect exec: %v", err)
|
||||
}
|
||||
|
||||
// Send the exit code
|
||||
slog.Debug("Sending exec exit code", "exec_id", execResp.ID, "exit_code", inspectResp.ExitCode)
|
||||
if err := stream.Send(&pb.ExecContainerResponse{
|
||||
Payload: &pb.ExecContainerResponse_ExitCode{ExitCode: int32(inspectResp.ExitCode)},
|
||||
}); err != nil {
|
||||
slog.Error("Failed to send exec exit code", "err", err, "exec_id", execResp.ID)
|
||||
return status.Errorf(codes.Internal, "send exit code: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Service provides higher-level Docker operations that extends Docker API with Uncloud-specific data
|
||||
// from the machine database.
|
||||
type Service struct {
|
||||
// Client is a Docker client for managing Docker resources.
|
||||
Client *client.Client
|
||||
// db is a connection to the machine database.
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
@@ -43,7 +53,7 @@ func (s *Service) InspectServiceContainer(ctx context.Context, nameOrID string)
|
||||
return serviceCtr, fmt.Errorf("container '%s' is not managed by Uncloud", nameOrID)
|
||||
}
|
||||
|
||||
serviceCtr.Container = api.Container{ContainerJSON: ctr}
|
||||
serviceCtr.Container = api.Container{InspectResponse: ctr}
|
||||
|
||||
// Retrieve ServiceSpec from the machine database.
|
||||
var specBytes []byte
|
||||
@@ -101,3 +111,147 @@ func (s *Service) ListServiceContainers(
|
||||
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
// IsContainerdImageStoreEnabled checks if Docker is configured to use the containerd image store:
|
||||
// https://docs.docker.com/engine/storage/containerd/
|
||||
func (s *Service) IsContainerdImageStoreEnabled(ctx context.Context) (bool, error) {
|
||||
info, err := s.Client.Info(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get Docker info: %w", err)
|
||||
}
|
||||
|
||||
return strings.Contains(fmt.Sprintf("%s", info.DriverStatus), "containerd.snapshotter"), nil
|
||||
}
|
||||
|
||||
type Images struct {
|
||||
// Images is a list of images present in the Docker image store (either internal or containerd).
|
||||
Images []image.Summary
|
||||
// ContainerdStore indicates whether Docker is using the containerd image store.
|
||||
ContainerdStore bool
|
||||
}
|
||||
|
||||
// ListImages lists Docker images with the given options and indicates whether Docker is using the containerd
|
||||
// image store. It always includes image manifests in the response if the store is containerd.
|
||||
func (s *Service) ListImages(ctx context.Context, opts image.ListOptions) (Images, error) {
|
||||
var imagesResp Images
|
||||
|
||||
// Always include the image manifests in the response.
|
||||
opts.Manifests = true
|
||||
images, err := s.Client.ImageList(ctx, opts)
|
||||
if err != nil {
|
||||
return imagesResp, status.Errorf(codes.Internal, "list images: %v", err)
|
||||
}
|
||||
|
||||
isContainerdStore, err := s.IsContainerdImageStoreEnabled(ctx)
|
||||
if err != nil {
|
||||
return imagesResp, status.Errorf(codes.Internal, "check if Docker uses containerd image store: %v", err)
|
||||
}
|
||||
|
||||
imagesResp = Images{
|
||||
Images: images,
|
||||
ContainerdStore: isContainerdStore,
|
||||
}
|
||||
|
||||
return imagesResp, nil
|
||||
}
|
||||
|
||||
// ContainerLogsOptions specifies parameters for ContainerLogs.
|
||||
type ContainerLogsOptions struct {
|
||||
ContainerID string
|
||||
Follow bool
|
||||
Tail int
|
||||
Since string
|
||||
Until string
|
||||
}
|
||||
|
||||
// ContainerLogs streams logs from a container and returns demultiplexed entries via a channel.
|
||||
// The channel is closed when streaming completes or context is cancelled.
|
||||
func (s *Service) ContainerLogs(ctx context.Context, opts ContainerLogsOptions) (<-chan api.ContainerLogEntry, error) {
|
||||
dockerOpts := container.LogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Follow: opts.Follow,
|
||||
Tail: strconv.FormatInt(int64(opts.Tail), 10),
|
||||
Since: opts.Since,
|
||||
Until: opts.Until,
|
||||
Timestamps: true,
|
||||
}
|
||||
|
||||
reader, err := s.Client.ContainerLogs(ctx, opts.ContainerID, dockerOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outCh := make(chan api.ContainerLogEntry)
|
||||
stdoutWriter := &logsChannelWriter{ctx: ctx, ch: outCh, isStderr: false}
|
||||
stderrWriter := &logsChannelWriter{ctx: ctx, ch: outCh, isStderr: true}
|
||||
|
||||
// Wrap the context in a cancellable one to unblock the second goroutine below when StdCopy completes.
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
// Run StdCopy in a goroutine to be able to handle context cancellation.
|
||||
go func() {
|
||||
defer close(outCh)
|
||||
defer cancel()
|
||||
|
||||
// StdCopy is blocking and will return when the reader is closed in another goroutine below or on error.
|
||||
if _, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, reader); err != nil {
|
||||
// Send error as the last entry.
|
||||
select {
|
||||
case outCh <- api.ContainerLogEntry{Err: fmt.Errorf("demultiplex container logs: %w", err)}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Close the reader when the context is done to cancel StdCopy if it's still running.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
reader.Close()
|
||||
}()
|
||||
|
||||
return outCh, nil
|
||||
}
|
||||
|
||||
// logsChannelWriter is a writer for stdcopy.StdCopy that sends demultiplexed container logs to a channel.
|
||||
type logsChannelWriter struct {
|
||||
ctx context.Context
|
||||
ch chan<- api.ContainerLogEntry
|
||||
isStderr bool
|
||||
}
|
||||
|
||||
func (w *logsChannelWriter) Write(data []byte) (n int, err error) {
|
||||
// Parse timestamp and message from the demultiplexed Docker log payload if the data looks like it contains one.
|
||||
// Format: 2025-01-01T00:00:00.000000000Z message
|
||||
timestamp := time.Time{}
|
||||
message := data
|
||||
if len(data) > 30 && data[4] == '-' && data[7] == '-' && data[10] == 'T' {
|
||||
timestampPart, messagePart, found := bytes.Cut(data, []byte(" "))
|
||||
if found {
|
||||
timestamp, err = time.Parse(time.RFC3339Nano, string(timestampPart))
|
||||
if err != nil {
|
||||
timestamp = time.Time{}
|
||||
}
|
||||
message = messagePart
|
||||
}
|
||||
}
|
||||
|
||||
entry := api.ContainerLogEntry{
|
||||
Timestamp: timestamp,
|
||||
// Clone is required because message is a slice into data, which stdcopy.StdCopy may reuse
|
||||
// after Write returns but before the entry is consumed from the channel.
|
||||
Message: bytes.Clone(message),
|
||||
}
|
||||
if w.isStderr {
|
||||
entry.Stream = api.LogStreamStderr
|
||||
} else {
|
||||
entry.Stream = api.LogStreamStdout
|
||||
}
|
||||
|
||||
select {
|
||||
case w.ch <- entry:
|
||||
return len(data), nil
|
||||
case <-w.ctx.Done():
|
||||
return 0, w.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package firewall
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// ConfigureIptablesChains is a stub for Darwin.
|
||||
func ConfigureIptablesChains() error {
|
||||
func ConfigureIptablesChains(machineIP netip.Addr) error {
|
||||
return fmt.Errorf("not supported on Darwin")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package firewall
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -18,7 +19,7 @@ const (
|
||||
)
|
||||
|
||||
// ConfigureIptablesChains sets up custom iptables chains and initial firewall rules for Uncloud networking.
|
||||
func ConfigureIptablesChains() error {
|
||||
func ConfigureIptablesChains(machineIP netip.Addr) error {
|
||||
if err := createIptablesChains(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -27,10 +28,24 @@ func ConfigureIptablesChains() error {
|
||||
ipt6 := iptables.GetIptable(iptables.IPv6)
|
||||
|
||||
// Allow WireGuard traffic to the machine.
|
||||
acceptWireGuardRule := []string{"-p", "udp", "--dport", strconv.Itoa(network.WireGuardPort), "-j", "ACCEPT"}
|
||||
err := ipt4.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, acceptWireGuardRule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(acceptWireGuardRule, " "), err)
|
||||
acceptWireGuardRule := []string{
|
||||
"-p", "udp",
|
||||
"--dport", strconv.Itoa(network.WireGuardPort),
|
||||
"-j", "ACCEPT",
|
||||
}
|
||||
// Allow cluster machines to access the unregistry (embedded image registry) on the machine to push/pull images.
|
||||
// TODO: allow access only from the machine IPs (10.210.N.1) but not the containers running on them. Use ipset?
|
||||
acceptUnregistryRule := []string{
|
||||
"-i", network.WireGuardInterfaceName,
|
||||
"-d", machineIP.String(),
|
||||
"-p", "tcp",
|
||||
"--dport", strconv.Itoa(constants.UnregistryPort),
|
||||
"-j", "ACCEPT",
|
||||
}
|
||||
for _, rule := range [][]string{acceptUnregistryRule, acceptWireGuardRule} {
|
||||
if err := ipt4.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
|
||||
return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(rule, " "), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow cluster machines to access Machine API via the management IPv6 WireGuard network.
|
||||
@@ -50,7 +65,7 @@ func ConfigureIptablesChains() error {
|
||||
"-j", "ACCEPT",
|
||||
}
|
||||
for _, rule := range [][]string{acceptMachineAPIRule, acceptCorrosionGossipRule} {
|
||||
if err = ipt6.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
|
||||
if err := ipt6.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
|
||||
return fmt.Errorf("insert ip6tables rule '%s': %w", strings.Join(rule, " "), err)
|
||||
}
|
||||
}
|
||||
|
||||
+182
-17
@@ -30,12 +30,15 @@ import (
|
||||
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/unregistry"
|
||||
"github.com/siderolabs/grpc-proxy/proxy"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -63,6 +66,8 @@ type Config struct {
|
||||
|
||||
// DockerClient manages system and user containers using the local Docker daemon.
|
||||
DockerClient *client.Client
|
||||
// ContainerdSockPath is the path to the containerd.sock used by Docker.
|
||||
ContainerdSockPath string
|
||||
|
||||
// CaddyConfigDir specifies the directory where the machine generates the Caddy reverse proxy configuration file
|
||||
// for routing external traffic to service containers across the internal network. Default is DataDir/caddy.
|
||||
@@ -93,6 +98,26 @@ func (c *Config) SetDefaults() (*Config, error) {
|
||||
}
|
||||
cfg.DockerClient = cli
|
||||
}
|
||||
if cfg.ContainerdSockPath == "" {
|
||||
// Auto-detect the containerd.sock path used by Docker.
|
||||
paths := []string{
|
||||
"/run/containerd/containerd.sock", // Default path on most Linux distributions.
|
||||
"/run/docker/containerd/containerd.sock",
|
||||
"/var/run/containerd/containerd.sock",
|
||||
"/var/run/docker/containerd/containerd.sock",
|
||||
}
|
||||
for _, path := range paths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
cfg.ContainerdSockPath = path
|
||||
slog.Debug("Detected containerd socket used by Docker.", "path", path)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.ContainerdSockPath == "" {
|
||||
slog.Warn("Failed to auto-detect containerd socket used by Docker.")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.CorrosionDir == "" {
|
||||
cfg.CorrosionDir = filepath.Join(cfg.DataDir, "corrosion")
|
||||
@@ -151,10 +176,13 @@ type Machine struct {
|
||||
state *State
|
||||
// started is closed when the machine is ready to serve requests on the local API server.
|
||||
started chan struct{}
|
||||
// initialised is signalled when the machine is configured as a member of a cluster.
|
||||
// initialised is closed when the machine is configured as a member of a cluster.
|
||||
initialised chan struct{}
|
||||
// networkReady is signalled when the Docker network is configured and ready for containers.
|
||||
// networkReady is closed when the Docker network is configured and ready for containers.
|
||||
networkReady chan struct{}
|
||||
// clusterReady is closed when the cluster controller has finished starting all components
|
||||
// and the machine is ready to serve cluster requests.
|
||||
clusterReady chan struct{}
|
||||
// resetting is true when the machine is being reset.
|
||||
resetting bool
|
||||
// stop cancels the Run method context to stop the machine gracefully.
|
||||
@@ -223,7 +251,10 @@ func NewMachine(config *Config) (*Machine, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create corrosion admin client: %w", err)
|
||||
}
|
||||
c := cluster.NewCluster(corroStore, corroAdmin)
|
||||
|
||||
initialised := make(chan struct{})
|
||||
clusterReady := make(chan struct{})
|
||||
c := cluster.NewCluster(corroStore, corroAdmin, initialised, clusterReady)
|
||||
|
||||
// Init dependencies for a gRPC Docker server that proxies requests to the local Docker daemon.
|
||||
dbFilePath := filepath.Join(config.DataDir, DBFileName)
|
||||
@@ -231,7 +262,6 @@ func NewMachine(config *Config) (*Machine, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init machine database: %w", err)
|
||||
}
|
||||
|
||||
dockerService := machinedocker.NewService(config.DockerClient, db)
|
||||
|
||||
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
||||
@@ -247,8 +277,9 @@ func NewMachine(config *Config) (*Machine, error) {
|
||||
config: *config,
|
||||
state: state,
|
||||
started: make(chan struct{}),
|
||||
initialised: make(chan struct{}, 1),
|
||||
initialised: initialised,
|
||||
networkReady: make(chan struct{}),
|
||||
clusterReady: clusterReady,
|
||||
store: corroStore,
|
||||
cluster: c,
|
||||
dockerService: dockerService,
|
||||
@@ -260,14 +291,19 @@ func NewMachine(config *Config) (*Machine, error) {
|
||||
internalDNSIP := func() netip.Addr {
|
||||
return m.IP()
|
||||
}
|
||||
m.dockerServer = machinedocker.NewServer(dockerService, db, internalDNSIP,
|
||||
machinedocker.WithNetworkReady(m.IsNetworkReady),
|
||||
machinedocker.WithWaitForNetworkReady(m.WaitForNetworkReady))
|
||||
// Machine ID will only be available after the machine is initialised as a cluster member so wrap it in a function.
|
||||
machineID := func() string {
|
||||
return m.state.ID
|
||||
}
|
||||
m.dockerServer = machinedocker.NewServer(dockerService, db, internalDNSIP, machineID, machinedocker.ServerOptions{
|
||||
NetworkReady: m.IsNetworkReady,
|
||||
WaitForNetworkReady: m.WaitForNetworkReady,
|
||||
})
|
||||
caddyServer := caddyconfig.NewServer(caddyconfig.NewService(config.CaddyConfigDir))
|
||||
m.localMachineServer = newGRPCServer(m, c, m.dockerServer, caddyServer)
|
||||
|
||||
if m.Initialised() {
|
||||
m.initialised <- struct{}{}
|
||||
close(m.initialised)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
@@ -403,11 +439,43 @@ func (m *Machine) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
dnsResolver := dns.NewClusterResolver(m.store)
|
||||
dnsServer, err := dns.NewServer(m.IP(), dnsResolver, m.config.DNSUpstreams)
|
||||
dnsServer, err := dns.NewServer(
|
||||
m.IP(),
|
||||
m.state.Network.Subnet,
|
||||
dnsResolver,
|
||||
m.config.DNSUpstreams,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create embedded DNS server: %w", err)
|
||||
}
|
||||
|
||||
var unreg *unregistry.Registry
|
||||
if m.config.ContainerdSockPath != "" {
|
||||
isContainerdStore, err := m.dockerService.IsContainerdImageStoreEnabled(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check if Docker uses containerd image store: %w", err)
|
||||
}
|
||||
|
||||
if isContainerdStore {
|
||||
// Create an embedded container registry listening on the machine IP address and
|
||||
// using the local Docker (containerd) image store as its backend.
|
||||
unreg, err = unregistry.NewRegistry(unregistry.Config{
|
||||
Addr: net.JoinHostPort(m.IP().String(), strconv.Itoa(constants.UnregistryPort)),
|
||||
ContainerdNamespace: "moby",
|
||||
ContainerdSock: m.config.ContainerdSockPath,
|
||||
LogFormatter: "text",
|
||||
LogLevel: "info",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create embedded registry: %w", err)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("Skipping embedded unregistry setup as Docker is not using the containerd image store.")
|
||||
}
|
||||
} else {
|
||||
slog.Warn("Skipping embedded unregistry setup as the containerd socket path is not configured.")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.clusterCtrl, err = newClusterController(
|
||||
m.state,
|
||||
@@ -416,9 +484,11 @@ func (m *Machine) Run(ctx context.Context) error {
|
||||
m.config.CorrosionService,
|
||||
m.dockerService,
|
||||
m.networkReady,
|
||||
m.clusterReady,
|
||||
caddyconfigCtrl,
|
||||
dnsServer,
|
||||
dnsResolver,
|
||||
unreg,
|
||||
)
|
||||
m.mu.Unlock()
|
||||
if err != nil {
|
||||
@@ -631,21 +701,30 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
|
||||
return nil, status.Errorf(codes.Internal, "generate machine name: %v", err)
|
||||
}
|
||||
}
|
||||
// Use the public and all routable IPs as endpoints.
|
||||
|
||||
// Use explicitly provided WireGuard endpoints, or use the routable IPs on the machine and its public IP
|
||||
// if not provided. The IPs are auto-detected.
|
||||
var endpoints []*pb.IPPort
|
||||
publicIP, pubIPErr := network.GetPublicIP()
|
||||
|
||||
if len(req.WireguardEndpoints) > 0 {
|
||||
endpoints = req.WireguardEndpoints
|
||||
} else {
|
||||
ips, err := network.ListRoutableIPs()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "list routable IPs: %v", err)
|
||||
}
|
||||
publicIP, pubIPErr := network.GetPublicIP()
|
||||
// Ignore the error if failed to get the public IP using API services.
|
||||
if pubIPErr == nil && !slices.Contains(ips, publicIP) {
|
||||
ips = append(ips, publicIP)
|
||||
}
|
||||
endpoints := make([]*pb.IPPort, len(ips))
|
||||
|
||||
endpoints = make([]*pb.IPPort, len(ips))
|
||||
for i, addr := range ips {
|
||||
addrPort := netip.AddrPortFrom(addr, network.WireGuardPort)
|
||||
endpoints[i] = pb.NewIPPort(addrPort)
|
||||
}
|
||||
}
|
||||
|
||||
// Register the new machine in the cluster to populate the state and get its ID and subnet.
|
||||
// Public and private keys have already been initialised in the machine state when it was created.
|
||||
@@ -662,7 +741,7 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
|
||||
addReq.PublicIp = pb.NewIP(publicIP)
|
||||
}
|
||||
|
||||
addResp, err := m.cluster.AddMachine(ctx, addReq)
|
||||
addResp, err := m.cluster.AddMachineWithoutReadyCheck(ctx, addReq)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "add machine to cluster: %v", err)
|
||||
}
|
||||
@@ -689,7 +768,7 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
|
||||
}
|
||||
slog.Info("Cluster initialised with machine.", "id", m.state.ID, "machine", m.state.Name)
|
||||
// Signal that the machine is initialised as a member of a cluster.
|
||||
m.initialised <- struct{}{}
|
||||
close(m.initialised)
|
||||
|
||||
resp := &pb.InitClusterResponse{
|
||||
Machine: addResp.Machine,
|
||||
@@ -732,6 +811,7 @@ func (m *Machine) JoinCluster(_ context.Context, req *pb.JoinClusterRequest) (*e
|
||||
PrivateKey: m.state.Network.PrivateKey,
|
||||
PublicKey: m.state.Network.PublicKey,
|
||||
}
|
||||
m.state.MinStoreDBVersion = req.MinStoreDbVersion
|
||||
|
||||
// Build a peers config from other cluster machines.
|
||||
m.state.Network.Peers = make([]network.PeerConfig, 0, len(req.OtherMachines))
|
||||
@@ -761,9 +841,16 @@ func (m *Machine) JoinCluster(_ context.Context, req *pb.JoinClusterRequest) (*e
|
||||
if err := m.state.Save(); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "save machine state: %v", err)
|
||||
}
|
||||
slog.Info("Machine configured to join the cluster.", "id", m.state.ID, "name", m.state.Name)
|
||||
slog.Info(
|
||||
"Machine configured to join the cluster.",
|
||||
"id", m.state.ID,
|
||||
"name", m.state.Name,
|
||||
"subnet", m.state.Network.Subnet.String(),
|
||||
"management_ip", m.state.Network.ManagementIP.String(),
|
||||
"peers", len(m.state.Network.Peers),
|
||||
)
|
||||
// Signal that the machine is initialised as a member of a cluster.
|
||||
m.initialised <- struct{}{}
|
||||
close(m.initialised)
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
@@ -797,6 +884,7 @@ func (m *Machine) Token(_ context.Context, _ *emptypb.Empty) (*pb.TokenResponse,
|
||||
return &pb.TokenResponse{Token: tokenStr}, nil
|
||||
}
|
||||
|
||||
// Deprecated: use InspectMachine instead.
|
||||
func (m *Machine) Inspect(_ context.Context, _ *emptypb.Empty) (*pb.MachineInfo, error) {
|
||||
return &pb.MachineInfo{
|
||||
Id: m.state.ID,
|
||||
@@ -809,6 +897,31 @@ func (m *Machine) Inspect(_ context.Context, _ *emptypb.Empty) (*pb.MachineInfo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Machine) InspectMachine(ctx context.Context, _ *emptypb.Empty) (*pb.InspectMachineResponse, error) {
|
||||
dbVersion, err := m.store.DBVersion(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "get database version of the cluster store: %v", err)
|
||||
}
|
||||
|
||||
return &pb.InspectMachineResponse{
|
||||
Machines: []*pb.MachineDetails{
|
||||
{
|
||||
// Metadata is injected by the gRPC proxy.
|
||||
Machine: &pb.MachineInfo{
|
||||
Id: m.state.ID,
|
||||
Name: m.state.Name,
|
||||
Network: &pb.NetworkConfig{
|
||||
Subnet: pb.NewIPPrefix(m.state.Network.Subnet),
|
||||
ManagementIp: pb.NewIP(m.state.Network.ManagementIP),
|
||||
PublicKey: m.state.Network.PublicKey,
|
||||
},
|
||||
},
|
||||
StoreDbVersion: dbVersion,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsNetworkReady returns true if the Docker network is ready for containers.
|
||||
func (m *Machine) IsNetworkReady() bool {
|
||||
if !m.Initialised() {
|
||||
@@ -842,6 +955,58 @@ func (m *Machine) WaitForNetworkReady(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
|
||||
func (m *Machine) InspectWireGuardNetwork(
|
||||
_ context.Context, _ *emptypb.Empty,
|
||||
) (*pb.InspectWireGuardNetworkResponse, error) {
|
||||
deviceName := network.WireGuardInterfaceName
|
||||
|
||||
wg, err := wgctrl.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create WireGuard client: %w", err)
|
||||
}
|
||||
defer wg.Close()
|
||||
|
||||
dev, err := wg.Device(deviceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get WireGuard device '%s': %w", deviceName, err)
|
||||
}
|
||||
|
||||
peers := make([]*pb.WireGuardPeer, len(dev.Peers))
|
||||
for i, p := range dev.Peers {
|
||||
var lastHandshake *timestamppb.Timestamp
|
||||
if !p.LastHandshakeTime.IsZero() {
|
||||
lastHandshake = timestamppb.New(p.LastHandshakeTime)
|
||||
}
|
||||
|
||||
allowedIPs := make([]string, len(p.AllowedIPs))
|
||||
for j, ip := range p.AllowedIPs {
|
||||
allowedIPs[j] = ip.String()
|
||||
}
|
||||
|
||||
var endpoint string
|
||||
if p.Endpoint != nil {
|
||||
endpoint = p.Endpoint.String()
|
||||
}
|
||||
|
||||
peers[i] = &pb.WireGuardPeer{
|
||||
PublicKey: p.PublicKey[:],
|
||||
Endpoint: endpoint,
|
||||
LastHandshakeTime: lastHandshake,
|
||||
ReceiveBytes: p.ReceiveBytes,
|
||||
TransmitBytes: p.TransmitBytes,
|
||||
AllowedIps: allowedIPs,
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.InspectWireGuardNetworkResponse{
|
||||
InterfaceName: dev.Name,
|
||||
PublicKey: dev.PublicKey[:],
|
||||
ListenPort: int32(dev.ListenPort),
|
||||
Peers: peers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Reset restores the machine to a clean state, scheduling a graceful shutdown and removing all cluster-related
|
||||
// configuration and resource. The uncloud daemon will restart the machine if managed by systemd.
|
||||
func (m *Machine) Reset(_ context.Context, _ *pb.ResetRequest) (*emptypb.Empty, error) {
|
||||
|
||||
@@ -31,6 +31,12 @@ func ListRoutableIPs() ([]netip.Addr, error) {
|
||||
// Skip Docker bridge interfaces.
|
||||
continue
|
||||
}
|
||||
if iface.Name == "tailscale0" {
|
||||
// Skip Tailscale interface as we don't want double-tunneling. Also, MTU=1280 on tailscale0 is too small
|
||||
// for tunneling IPv6 used for uncloud management traffic.
|
||||
// See Discord thread: https://discord.com/channels/1371726032104587335/1477823130670993408
|
||||
continue
|
||||
}
|
||||
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagRunning == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
// Skip interfaces:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user