mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.2"
|
go-version: "1.25.1"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
- name: Install Mise
|
- name: Install Mise
|
||||||
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
|
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
|
||||||
with:
|
with:
|
||||||
version: "2025.6.5"
|
version: "2025.9.6"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.2"
|
go-version: "1.25.1"
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||||
with:
|
with:
|
||||||
version: v2.2.2
|
version: v2.4.0
|
||||||
|
|
||||||
- name: Format code
|
- name: Format code
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+76
-26
@@ -1,47 +1,97 @@
|
|||||||
[tools."aqua:vektra/mockery"]
|
[[tools."aqua:vektra/mockery"]]
|
||||||
version = "3.5.3"
|
version = "3.5.3"
|
||||||
backend = "aqua:vektra/mockery"
|
backend = "aqua:vektra/mockery"
|
||||||
|
|
||||||
[tools."aqua:vektra/mockery".checksums]
|
[tools."aqua:vektra/mockery".platforms.linux-x64]
|
||||||
"mockery_3.5.3_Darwin_arm64.tar.gz" = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
|
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
|
||||||
"mockery_3.5.3_Linux_x86_64.tar.gz" = "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]
|
[tools."aqua:vektra/mockery".platforms.macos-arm64]
|
||||||
version = "1.23.10"
|
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"
|
backend = "core:go"
|
||||||
|
|
||||||
[tools.go.checksums]
|
[tools.go.platforms.linux-x64]
|
||||||
"go1.23.10.darwin-arm64.tar.gz" = "sha256:25c64bfa8a8fd8e7f62fb54afa4354af8409a4bb2358c2699a1003b733e6fce5"
|
checksum = "sha256:7716a0d940a0f6ae8e1f3b3f4f36299dc53e31b16840dbd171254312c41ca12e"
|
||||||
"go1.23.10.linux-amd64.tar.gz" = "sha256:535f9f81802499f2a7dbfa70abb8fda3793725fcc29460f719815f6e10b5fd60"
|
size = 59660846
|
||||||
|
url = "https://dl.google.com/go/go1.25.1.linux-amd64.tar.gz"
|
||||||
|
|
||||||
[tools.golangci-lint]
|
[tools.go.platforms.macos-arm64]
|
||||||
version = "2.2.2"
|
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"
|
backend = "aqua:golangci/golangci-lint"
|
||||||
|
|
||||||
[tools.golangci-lint.checksums]
|
[tools.golangci-lint.platforms.linux-x64]
|
||||||
"golangci-lint-2.2.2-darwin-arm64.tar.gz" = "sha256:d84d94d042c0d495fd1746f3d18948a75de163b17a14e8de3ef840928dd2df74"
|
checksum = "sha256:fae792524c04424c0ac369f5b8076f04b45cf29fc945a370e55d369a8dc11840"
|
||||||
"golangci-lint-2.2.2-linux-amd64.tar.gz" = "sha256:c27fbde948a87d326feacd21df2f61a9c54dbd2e3bfa185c0a1cd6917a6f964f"
|
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"
|
version = "27.3"
|
||||||
backend = "aqua:protocolbuffers/protobuf/protoc"
|
backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||||
|
|
||||||
[tools.protoc.checksums]
|
[tools.protoc.platforms.linux-x64]
|
||||||
"protoc-27.3-linux-x86_64.zip" = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
|
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
|
||||||
"protoc-27.3-osx-aarch_64.zip" = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
|
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"
|
version = "1.34.2"
|
||||||
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
|
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
|
||||||
|
|
||||||
[tools.protoc-gen-go.checksums]
|
[tools.protoc-gen-go.platforms.linux-x64]
|
||||||
"protoc-gen-go.v1.34.2.darwin.arm64.tar.gz" = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
|
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
|
||||||
"protoc-gen-go.v1.34.2.linux.amd64.tar.gz" = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
|
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"
|
version = "1.5.1"
|
||||||
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
|
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
|
||||||
|
|
||||||
[tools.protoc-gen-go-grpc.checksums]
|
[tools.protoc-gen-go-grpc.platforms.linux-x64]
|
||||||
"protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz" = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
|
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
|
||||||
"protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz" = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
|
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]
|
[tools]
|
||||||
"aqua:vektra/mockery" = "3.5.3"
|
"aqua:vektra/mockery" = "3.5.3"
|
||||||
go = "1.23"
|
go = "1.25.1"
|
||||||
golangci-lint = "2.2.2"
|
golangci-lint = "2.4.0"
|
||||||
|
node = "22.20.0"
|
||||||
protoc = "27.3"
|
protoc = "27.3"
|
||||||
protoc-gen-go = "1.34.2"
|
protoc-gen-go = "1.34.2"
|
||||||
protoc-gen-go-grpc = "1.5.1"
|
protoc-gen-go-grpc = "1.5.1"
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ This document provides comprehensive information about the Uncloud project for A
|
|||||||
- **`scripts/`**: Installation and utility scripts
|
- **`scripts/`**: Installation and utility scripts
|
||||||
- **`test/`**: Test suites and test infrastructure
|
- **`test/`**: Test suites and test infrastructure
|
||||||
- **`website/`**: Documentation website (Docusaurus)
|
- **`website/`**: Documentation website (Docusaurus)
|
||||||
|
|
||||||
|
- `landing/`: Landing page
|
||||||
|
- `docs/`: User documentation
|
||||||
|
|
||||||
- **`misc/`**: Design documents and guides
|
- **`misc/`**: Design documents and guides
|
||||||
|
|
||||||
## Key Technologies
|
## Key Technologies
|
||||||
@@ -184,9 +188,11 @@ uc context use <name> # Switch context
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- Unit tests alongside source files (`*_test.go`)
|
- Test files and locations
|
||||||
- Integration tests in `test/e2e/`
|
- Unit tests alongside source files (`*_test.go`)
|
||||||
- Test fixtures in `test/fixtures/`
|
- Integration tests in `test/e2e/`
|
||||||
|
- Test fixtures in `test/fixtures/`
|
||||||
|
- Use table driven tests whenever possible
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
@@ -233,7 +239,7 @@ uc context use <name> # Switch context
|
|||||||
- `internal/machine/machine.go`: Core machine management
|
- `internal/machine/machine.go`: Core machine management
|
||||||
- `pkg/api/`: Public API definitions
|
- `pkg/api/`: Public API definitions
|
||||||
- `misc/design.md`: Architecture and design philosophy
|
- `misc/design.md`: Architecture and design philosophy
|
||||||
- `README.md`: User-facing documentation
|
- `README.md`: Repository README
|
||||||
|
|
||||||
### Configuration Files
|
### Configuration Files
|
||||||
|
|
||||||
@@ -242,3 +248,11 @@ uc context use <name> # Switch context
|
|||||||
- `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing)
|
- `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.
|
||||||
|
- Place the subject before the action whenever possible. Example: prefer “The function loads data” over “Data is loaded by the function.”
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
ARG ALPINE_VERSION=3.20.3
|
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 TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ demo-reset:
|
|||||||
|
|
||||||
.PHONY: ucind-cluster
|
.PHONY: ucind-cluster
|
||||||
ucind-cluster:
|
ucind-cluster:
|
||||||
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
|
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
|
||||||
|
|
||||||
.PHONY: proto
|
.PHONY: proto
|
||||||
proto:
|
proto:
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
var clusterContext string
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
@@ -24,11 +22,6 @@ func NewListCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&clusterContext, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +44,7 @@ func list(uncli *cli.CLI) error {
|
|||||||
for _, name := range contextNames {
|
for _, name := range contextNames {
|
||||||
current := ""
|
current := ""
|
||||||
if name == currentContext {
|
if name == currentContext {
|
||||||
current = "*"
|
current = "✓"
|
||||||
}
|
}
|
||||||
connCount := len(uncli.Config.Contexts[name].Connections)
|
connCount := len(uncli.Config.Contexts[name].Connections)
|
||||||
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
||||||
|
|||||||
+25
-11
@@ -22,6 +22,7 @@ type deployOptions struct {
|
|||||||
services []string
|
services []string
|
||||||
noBuild bool
|
noBuild bool
|
||||||
recreate bool
|
recreate bool
|
||||||
|
yes bool
|
||||||
|
|
||||||
context string
|
context string
|
||||||
}
|
}
|
||||||
@@ -33,6 +34,8 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
Use: "deploy [FLAGS] [SERVICE...]",
|
Use: "deploy [FLAGS] [SERVICE...]",
|
||||||
Short: "Deploy services from a Compose file.",
|
Short: "Deploy services from a Compose file.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||||
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@@ -43,16 +46,19 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
|
||||||
"One or more Compose files to deploy services from. (default compose.yaml)")
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
|
||||||
"One or more Compose profiles to enable.")
|
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
"Name of the cluster context to deploy to (default is the current context)")
|
"Name of the cluster context to deploy to (default is the current context)")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||||
|
"One or more Compose files to deploy services from. (default compose.yaml)")
|
||||||
cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false,
|
cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false,
|
||||||
"Do not build images before deploying services. (default false)")
|
"Do not build images before deploying services. (default false)")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||||
|
"One or more Compose profiles to enable.")
|
||||||
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
||||||
"Recreate containers even if their configuration and image haven't changed.")
|
"Recreate containers even if their configuration and image haven't changed.")
|
||||||
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
|
"Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+
|
||||||
|
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||||
|
|
||||||
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
||||||
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
||||||
@@ -136,13 +142,21 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
confirmed, err := cli.Confirm()
|
// Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
|
||||||
if err != nil {
|
if !opts.yes {
|
||||||
return fmt.Errorf("confirm deployment: %w", err)
|
if !cli.IsStdinTerminal() {
|
||||||
}
|
return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " +
|
||||||
if !confirmed {
|
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
|
||||||
fmt.Println("Cancelled. No changes were made.")
|
}
|
||||||
return nil
|
|
||||||
|
confirmed, err := cli.Confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm deployment: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. No changes were made.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
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
|
||||||
|
context 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)")
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
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, opts.context)
|
||||||
|
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,100 @@
|
|||||||
|
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
|
||||||
|
context 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 the machine which CLI is connected to (default) or the specified machine(s).`,
|
||||||
|
Example: ` # Push image to the machine the CLI is connected to.
|
||||||
|
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 image to all machines in the cluster.
|
||||||
|
uc image push myapp:latest -m all
|
||||||
|
|
||||||
|
# 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 to push the image to. Can be specified multiple times or as a comma-separated "+
|
||||||
|
"list of machine names.\n"+
|
||||||
|
"Use 'all' to push to all machines. (default is connected machine)")
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
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 "all" keyword to push to all machines.
|
||||||
|
if len(machines) == 1 && machines[0] == "all" {
|
||||||
|
pushOpts.AllMachines = true
|
||||||
|
} else {
|
||||||
|
pushOpts.Machines = machines
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -20,12 +20,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type addOptions struct {
|
type addOptions struct {
|
||||||
name string
|
name string
|
||||||
noCaddy bool
|
noCaddy bool
|
||||||
publicIP string
|
noInstall bool
|
||||||
sshKey string
|
publicIP string
|
||||||
context string
|
sshKey string
|
||||||
version string
|
context string
|
||||||
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAddCommand() *cobra.Command {
|
func NewAddCommand() *cobra.Command {
|
||||||
@@ -56,6 +57,11 @@ func NewAddCommand() *cobra.Command {
|
|||||||
&opts.noCaddy, "no-caddy", false,
|
&opts.noCaddy, "no-caddy", false,
|
||||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||||
)
|
)
|
||||||
|
cmd.Flags().BoolVar(
|
||||||
|
&opts.noInstall, "no-install", false,
|
||||||
|
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
||||||
|
"Assumes they're already installed and running.",
|
||||||
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.publicIP, "public-ip", "auto",
|
&opts.publicIP, "public-ip", "auto",
|
||||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||||
@@ -98,6 +104,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
|||||||
MachineName: opts.name,
|
MachineName: opts.name,
|
||||||
PublicIP: publicIP,
|
PublicIP: publicIP,
|
||||||
RemoteMachine: remoteMachine,
|
RemoteMachine: remoteMachine,
|
||||||
|
SkipInstall: opts.noInstall,
|
||||||
Version: opts.version,
|
Version: opts.version,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type initOptions struct {
|
|||||||
network string
|
network string
|
||||||
noCaddy bool
|
noCaddy bool
|
||||||
noDNS bool
|
noDNS bool
|
||||||
|
noInstall bool
|
||||||
publicIP string
|
publicIP string
|
||||||
sshKey string
|
sshKey string
|
||||||
version string
|
version string
|
||||||
@@ -33,7 +34,20 @@ func NewInitCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init [USER@HOST:PORT]",
|
Use: "init [USER@HOST:PORT]",
|
||||||
Short: "Initialise a new cluster with a remote machine as the first member.",
|
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.\n" +
|
||||||
|
"This command creates a new context in your Uncloud config to manage the cluster.",
|
||||||
|
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.cluster.uncloud.run).
|
||||||
|
# You can deploy Caddy with 'uc caddy deploy' and reserve a domain with 'uc dns reserve' later.
|
||||||
|
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
|
||||||
// TODO: support initialising a cluster on the local machine.
|
// TODO: support initialising a cluster on the local machine.
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -68,11 +82,16 @@ func NewInitCommand() *cobra.Command {
|
|||||||
)
|
)
|
||||||
cmd.Flags().BoolVar(
|
cmd.Flags().BoolVar(
|
||||||
&opts.noCaddy, "no-caddy", false,
|
&opts.noCaddy, "no-caddy", false,
|
||||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
"Don't deploy Caddy reverse proxy service to the machine. You can deploy it later with 'uc caddy deploy'.",
|
||||||
)
|
)
|
||||||
cmd.Flags().BoolVar(
|
cmd.Flags().BoolVar(
|
||||||
&opts.noDNS, "no-dns", false,
|
&opts.noDNS, "no-dns", false,
|
||||||
"Don't reserve a cluster domain in Uncloud DNS.",
|
"Don't reserve a cluster domain in Uncloud DNS. You can reserve it later with 'uc dns reserve'.",
|
||||||
|
)
|
||||||
|
cmd.Flags().BoolVar(
|
||||||
|
&opts.noInstall, "no-install", false,
|
||||||
|
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
||||||
|
"Assumes they're already installed and running.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.publicIP, "public-ip", "auto",
|
&opts.publicIP, "public-ip", "auto",
|
||||||
@@ -90,7 +109,7 @@ func NewInitCommand() *cobra.Command {
|
|||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.context, "context", "c", cli.DefaultContextName,
|
&opts.context, "context", "c", cli.DefaultContextName,
|
||||||
"Name of the created context for the initialised cluster in the Uncloud config.",
|
"Name of the new context to be created in the Uncloud config to manage the cluster.",
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -121,6 +140,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
Network: netPrefix,
|
Network: netPrefix,
|
||||||
PublicIP: publicIP,
|
PublicIP: publicIP,
|
||||||
RemoteMachine: remoteMachine,
|
RemoteMachine: remoteMachine,
|
||||||
|
SkipInstall: opts.noInstall,
|
||||||
Version: opts.version,
|
Version: opts.version,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
|||||||
// Print the list of machines in a table format.
|
// Print the list of machines in a table format.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
// Print header.
|
// Print header.
|
||||||
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS"); err != nil {
|
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS\tMACHINE ID"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
// Print rows.
|
// Print rows.
|
||||||
@@ -68,8 +68,8 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err = fmt.Fprintf(
|
if _, err = fmt.Fprintf(
|
||||||
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
tw, "%s\t%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||||
strings.Join(endpoints, ", "),
|
strings.Join(endpoints, ", "), member.Machine.Id,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-4
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/image"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
||||||
@@ -28,11 +29,14 @@ func main() {
|
|||||||
opts := globalOptions{}
|
opts := globalOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uc",
|
Use: "uc",
|
||||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.",
|
||||||
Version: version.String(),
|
Version: version.String(),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
|
||||||
|
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
|
||||||
|
|
||||||
var conn *config.MachineConnection
|
var conn *config.MachineConnection
|
||||||
if opts.connect != "" {
|
if opts.connect != "" {
|
||||||
if strings.HasPrefix(opts.connect, "tcp://") {
|
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||||
@@ -65,11 +69,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||||
"Connect to a remote cluster machine without using the Uncloud configuration file.\n"+
|
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
||||||
"Format: [ssh://]user@host[:port] or tcp://host:port")
|
"Format: [ssh://]user@host[:port] or tcp://host:port")
|
||||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
|
||||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||||
"Path to the Uncloud configuration file.")
|
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
||||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||||
// TODO: make --context a global flag and pass it as a value of the command context.
|
// TODO: make --context a global flag and pass it as a value of the command context.
|
||||||
|
|
||||||
@@ -77,9 +80,11 @@ func main() {
|
|||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
NewDocsCommand(),
|
NewDocsCommand(),
|
||||||
NewBuildCommand(),
|
NewBuildCommand(),
|
||||||
|
NewImagesCommand(),
|
||||||
caddy.NewRootCommand(),
|
caddy.NewRootCommand(),
|
||||||
cmdcontext.NewRootCommand(),
|
cmdcontext.NewRootCommand(),
|
||||||
dns.NewRootCommand(),
|
dns.NewRootCommand(),
|
||||||
|
image.NewRootCommand(),
|
||||||
machine.NewRootCommand(),
|
machine.NewRootCommand(),
|
||||||
service.NewRootCommand(),
|
service.NewRootCommand(),
|
||||||
service.NewInspectCommand(),
|
service.NewInspectCommand(),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
service string
|
service string
|
||||||
cluster string
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInspectCommand() *cobra.Command {
|
func NewInspectCommand() *cobra.Command {
|
||||||
@@ -31,14 +31,14 @@ func NewInspectCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "context", "c", "",
|
&opts.context, "context", "c", "",
|
||||||
"Name of the cluster context. (default is the current context)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -12,26 +12,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
// TODO(lhf): rename to context
|
var contextName string
|
||||||
var cluster string
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List services.",
|
Short: "List services.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return list(cmd.Context(), uncli, cluster)
|
return list(cmd.Context(), uncli, contextName)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&cluster, "context", "c", "",
|
&contextName, "context", "c", "",
|
||||||
"Name of the cluster context. (default is the current context)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
func list(ctx context.Context, uncli *cli.CLI, contextName string) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, clusterName)
|
client, err := uncli.ConnectCluster(ctx, contextName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -61,19 +60,20 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
|||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tENDPOINTS"); err != nil {
|
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tIMAGE\tENDPOINTS"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
for _, s := range services {
|
for _, s := range services {
|
||||||
endpointsSlice := s.Endpoints()
|
images := strings.Join(s.Images(), ", ")
|
||||||
endpoints := strings.Join(endpointsSlice, ", ")
|
endpoints := strings.Join(s.Endpoints(), ", ")
|
||||||
|
|
||||||
if haveDuplicateNames {
|
if haveDuplicateNames {
|
||||||
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", s.Name, s.Mode, len(s.Containers), endpoints); err != nil {
|
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n",
|
||||||
|
s.Name, s.Mode, len(s.Containers), images, endpoints); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
type rmOptions struct {
|
type rmOptions struct {
|
||||||
services []string
|
services []string
|
||||||
cluster string
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRmCommand() *cobra.Command {
|
func NewRmCommand() *cobra.Command {
|
||||||
@@ -28,14 +28,14 @@ func NewRmCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "context", "c", "",
|
&opts.context, "context", "c", "",
|
||||||
"Name of the cluster context. (default is the current context)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -50,5 +50,5 @@ func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
|||||||
}, uncli.ProgressOut(), "Removing service "+s)
|
}, uncli.ProgressOut(), "Removing service "+s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ type runOptions struct {
|
|||||||
user string
|
user string
|
||||||
volumes []string
|
volumes []string
|
||||||
|
|
||||||
cluster string
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRunCommand() *cobra.Command {
|
func NewRunCommand() *cobra.Command {
|
||||||
@@ -113,7 +113,7 @@ func NewRunCommand() *cobra.Command {
|
|||||||
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "context", "c", "",
|
&opts.context, "context", "c", "",
|
||||||
"Name of the cluster context to run the service in. (default is the current context)",
|
"Name of the cluster context to run the service in. (default is the current context)",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
type scaleOptions struct {
|
type scaleOptions struct {
|
||||||
service string
|
service string
|
||||||
replicas uint
|
replicas uint
|
||||||
cluster string
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewScaleCommand() *cobra.Command {
|
func NewScaleCommand() *cobra.Command {
|
||||||
@@ -40,7 +40,7 @@ func NewScaleCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "context", "c", "",
|
&opts.context, "context", "c", "",
|
||||||
"Name of the cluster context. (default is the current context)",
|
"Name of the cluster context. (default is the current context)",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
module github.com/psviderski/uncloud
|
module github.com/psviderski/uncloud
|
||||||
|
|
||||||
go 1.23.0
|
go 1.25
|
||||||
|
|
||||||
require (
|
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/semver v1.5.0
|
||||||
github.com/Masterminds/squirrel v1.5.4
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0
|
github.com/alecthomas/chroma/v2 v2.20.0
|
||||||
github.com/caddyserver/caddy/v2 v2.8.4
|
github.com/caddyserver/caddy/v2 v2.8.4
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
|
github.com/charmbracelet/bubbles v0.20.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.9
|
||||||
github.com/charmbracelet/huh v0.6.0
|
github.com/charmbracelet/huh v0.6.0
|
||||||
github.com/charmbracelet/lipgloss v0.13.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/compose-spec/compose-go/v2 v2.4.5
|
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/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||||
github.com/deckarep/golang-set/v2 v2.8.0
|
github.com/deckarep/golang-set/v2 v2.8.0
|
||||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v27.5.0+incompatible
|
github.com/docker/cli v28.5.0+incompatible
|
||||||
github.com/docker/compose/v2 v2.31.0
|
github.com/docker/compose/v2 v2.40.0
|
||||||
github.com/docker/docker v27.4.0-rc.2+incompatible
|
github.com/docker/docker v28.5.0+incompatible
|
||||||
github.com/docker/go-connections v0.5.0
|
github.com/docker/go-connections v0.6.0
|
||||||
github.com/docker/go-units v0.5.0
|
github.com/docker/go-units v0.5.0
|
||||||
github.com/goccy/go-yaml v1.17.1
|
github.com/goccy/go-yaml v1.17.1
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
@@ -37,39 +41,43 @@ require (
|
|||||||
github.com/lmittmann/tint v1.0.5
|
github.com/lmittmann/tint v1.0.5
|
||||||
github.com/miekg/dns v1.1.65
|
github.com/miekg/dns v1.1.65
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/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/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.3.1
|
||||||
github.com/siderolabs/discovery-api v0.1.4
|
github.com/siderolabs/discovery-api v0.1.4
|
||||||
github.com/siderolabs/discovery-client v0.1.9
|
github.com/siderolabs/discovery-client v0.1.9
|
||||||
github.com/siderolabs/grpc-proxy v0.5.1
|
github.com/siderolabs/grpc-proxy v0.5.1
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.1
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||||
golang.org/x/crypto v0.33.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/net v0.35.0
|
golang.org/x/net v0.43.0
|
||||||
golang.org/x/sync v0.11.0
|
golang.org/x/sync v0.17.0
|
||||||
golang.org/x/sys v0.31.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 v0.0.0-20231211153847-12269c276173
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a
|
||||||
google.golang.org/grpc v1.68.1
|
google.golang.org/grpc v1.74.2
|
||||||
google.golang.org/protobuf v1.36.3
|
google.golang.org/protobuf v1.36.9
|
||||||
modernc.org/sqlite v1.36.3
|
modernc.org/sqlite v1.36.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/kms v1.19.0 // indirect
|
||||||
|
cloud.google.com/go/longrunning v0.6.0 // indirect
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
|
|
||||||
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
|
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||||
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/OneOfOne/xxhash v1.2.8 // indirect
|
github.com/Microsoft/hcsshim v0.13.0 // indirect
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
||||||
github.com/armon/go-metrics v0.4.1 // indirect
|
github.com/armon/go-metrics v0.4.1 // indirect
|
||||||
@@ -85,37 +93,40 @@ require (
|
|||||||
github.com/catppuccin/go v0.2.0 // indirect
|
github.com/catppuccin/go v0.2.0 // indirect
|
||||||
github.com/cespare/xxhash v1.1.0 // indirect
|
github.com/cespare/xxhash v1.1.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/bubbles v0.20.0 // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.1.1 // indirect
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.3.2 // 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/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/chzyer/readline v1.5.1 // indirect
|
||||||
github.com/cloudflare/cfssl v1.6.4 // indirect
|
github.com/containerd/cgroups/v3 v3.0.5 // indirect
|
||||||
github.com/containerd/console v1.0.4 // indirect
|
github.com/containerd/console v1.0.5 // indirect
|
||||||
github.com/containerd/containerd v1.7.24 // indirect
|
github.com/containerd/containerd/api v1.9.0 // indirect
|
||||||
github.com/containerd/containerd/api v1.7.19 // indirect
|
github.com/containerd/containerd/v2 v2.1.4 // indirect
|
||||||
github.com/containerd/continuity v0.4.4 // indirect
|
github.com/containerd/continuity v0.4.5 // indirect
|
||||||
github.com/containerd/errdefs v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
|
github.com/containerd/fifo v1.1.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/containerd/platforms v0.2.1 // indirect
|
github.com/containerd/plugin v1.0.0 // indirect
|
||||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||||
github.com/containerd/ttrpc v1.2.5 // indirect
|
github.com/containerd/ttrpc v1.2.7 // indirect
|
||||||
github.com/containerd/typeurl/v2 v2.2.0 // indirect
|
github.com/containerd/typeurl/v2 v2.2.3 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||||
github.com/dgraph-io/badger v1.6.2 // indirect
|
github.com/dgraph-io/badger v1.6.2 // indirect
|
||||||
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
||||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
||||||
|
github.com/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/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/distribution v2.8.3+incompatible // indirect
|
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||||
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
|
||||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
||||||
github.com/docker/go-metrics v0.0.1 // indirect
|
github.com/docker/go-metrics v0.0.1 // indirect
|
||||||
|
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fatih/color v1.17.0 // indirect
|
github.com/fatih/color v1.17.0 // indirect
|
||||||
@@ -125,28 +136,30 @@ require (
|
|||||||
github.com/go-kit/kit v0.13.0 // indirect
|
github.com/go-kit/kit v0.13.0 // indirect
|
||||||
github.com/go-kit/log v0.2.1 // indirect
|
github.com/go-kit/log v0.2.1 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/gofrs/flock v0.12.1 // indirect
|
github.com/gofrs/flock v0.12.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/glog v1.2.2 // indirect
|
github.com/golang/glog v1.2.5 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/google/btree v1.1.2 // indirect
|
github.com/google/btree v1.1.2 // indirect
|
||||||
github.com/google/cel-go v0.20.1 // indirect
|
github.com/google/cel-go v0.20.1 // indirect
|
||||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/handlers v1.5.2 // indirect
|
||||||
github.com/gorilla/mux v1.8.1 // indirect
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
|
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
|
||||||
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
||||||
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
|
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
@@ -154,12 +167,13 @@ require (
|
|||||||
github.com/hashicorp/go-syslog v1.0.0 // indirect
|
github.com/hashicorp/go-syslog v1.0.0 // indirect
|
||||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/arc/v2 v2.0.7 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/hashicorp/logutils v1.0.0 // indirect
|
github.com/hashicorp/logutils v1.0.0 // indirect
|
||||||
github.com/hashicorp/mdns v1.0.5 // indirect
|
github.com/hashicorp/mdns v1.0.5 // indirect
|
||||||
github.com/huandu/xstrings v1.5.0 // indirect
|
github.com/huandu/xstrings v1.5.0 // indirect
|
||||||
github.com/imdario/mergo v0.3.16 // indirect
|
github.com/imdario/mergo v0.3.16 // indirect
|
||||||
github.com/in-toto/in-toto-golang v0.5.0 // indirect
|
github.com/in-toto/in-toto-golang v0.9.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||||
github.com/ipfs/go-block-format v0.2.0 // indirect
|
github.com/ipfs/go-block-format v0.2.0 // indirect
|
||||||
@@ -179,7 +193,7 @@ require (
|
|||||||
github.com/jackc/pgx/v4 v4.18.3 // indirect
|
github.com/jackc/pgx/v4 v4.18.3 // indirect
|
||||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||||
github.com/josharian/native v1.1.0 // indirect
|
github.com/josharian/native v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.17.11 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||||
@@ -200,7 +214,6 @@ require (
|
|||||||
github.com/mdlayher/socket v0.5.1 // indirect
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||||
github.com/mholt/acmez/v2 v2.0.3 // indirect
|
github.com/mholt/acmez/v2 v2.0.3 // indirect
|
||||||
github.com/miekg/pkcs11 v1.1.1 // indirect
|
|
||||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||||
github.com/mitchellh/cli v1.1.5 // indirect
|
github.com/mitchellh/cli v1.1.5 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
@@ -208,19 +221,21 @@ require (
|
|||||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/moby/buildkit v0.17.2 // indirect
|
github.com/moby/buildkit v0.25.0 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
|
github.com/moby/go-archive v0.1.0 // indirect
|
||||||
github.com/moby/locker v1.0.1 // indirect
|
github.com/moby/locker v1.0.1 // indirect
|
||||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
|
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||||
|
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
||||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||||
github.com/moby/sys/signal v0.7.1 // indirect
|
github.com/moby/sys/signal v0.7.1 // indirect
|
||||||
github.com/moby/sys/user v0.3.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/morikuni/aec v1.0.0 // indirect
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
|
|
||||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||||
github.com/multiformats/go-multiaddr v0.13.0 // indirect
|
github.com/multiformats/go-multiaddr v0.13.0 // indirect
|
||||||
@@ -233,25 +248,31 @@ require (
|
|||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||||
|
github.com/opencontainers/runtime-spec v1.2.1 // indirect
|
||||||
|
github.com/opencontainers/selinux v1.12.0 // indirect
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
github.com/pires/go-proxyproto v0.7.0 // indirect
|
github.com/pires/go-proxyproto v0.7.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/polydawn/refmt v0.89.0 // indirect
|
github.com/polydawn/refmt v0.89.0 // indirect
|
||||||
github.com/posener/complete v1.2.3 // indirect
|
github.com/posener/complete v1.2.3 // indirect
|
||||||
github.com/prometheus/client_golang v1.20.5 // indirect
|
github.com/prometheus/client_golang v1.22.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/prometheus/common v0.61.0 // indirect
|
github.com/prometheus/common v0.62.0 // indirect
|
||||||
github.com/prometheus/procfs v0.15.1 // indirect
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||||
|
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
|
||||||
|
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
|
||||||
|
github.com/redis/go-redis/v9 v9.7.3 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/xid v1.5.0 // indirect
|
github.com/rs/xid v1.5.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
|
||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
||||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
github.com/secure-systems-lab/go-securesystemslib v0.6.0 // indirect
|
||||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
@@ -260,60 +281,70 @@ require (
|
|||||||
github.com/slackhq/nebula v1.6.1 // indirect
|
github.com/slackhq/nebula v1.6.1 // indirect
|
||||||
github.com/smallstep/certificates v0.26.1 // indirect
|
github.com/smallstep/certificates v0.26.1 // indirect
|
||||||
github.com/smallstep/nosql v0.6.1 // indirect
|
github.com/smallstep/nosql v0.6.1 // indirect
|
||||||
github.com/smallstep/pkcs7 v0.0.0-20231024181729-3b98ecc1ca81 // indirect
|
github.com/smallstep/pkcs7 v0.1.1 // indirect
|
||||||
github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
|
github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
|
||||||
github.com/smallstep/truststore v0.13.0 // indirect
|
github.com/smallstep/truststore v0.13.0 // indirect
|
||||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/spf13/cast v1.7.0 // indirect
|
github.com/spf13/cast v1.7.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
||||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect
|
||||||
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
|
github.com/tonistiigi/fsutil v0.0.0-20250605211040-586307ad452f // indirect
|
||||||
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c // indirect
|
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect
|
||||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
|
|
||||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
||||||
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
|
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
|
||||||
github.com/urfave/cli v1.22.16 // indirect
|
github.com/urfave/cli v1.22.17 // indirect
|
||||||
github.com/vbatts/tar-split v0.11.6 // indirect
|
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
|
||||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
go.etcd.io/bbolt v1.3.11 // indirect
|
go.etcd.io/bbolt v1.4.3 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
|
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
|
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.33.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
|
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.33.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.33.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/log v0.8.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||||
go.step.sm/cli-utils v0.9.0 // indirect
|
go.step.sm/cli-utils v0.9.0 // indirect
|
||||||
go.step.sm/crypto v0.45.0 // indirect
|
go.step.sm/crypto v0.45.0 // indirect
|
||||||
go.step.sm/linkedca v0.20.1 // indirect
|
go.step.sm/linkedca v0.20.1 // indirect
|
||||||
go.uber.org/automaxprocs v1.5.3 // indirect
|
go.uber.org/automaxprocs v1.5.3 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap/exp v0.2.0 // indirect
|
go.uber.org/zap/exp v0.2.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
|
||||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
golang.org/x/mod v0.23.0 // indirect
|
golang.org/x/mod v0.27.0 // indirect
|
||||||
golang.org/x/term v0.29.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/time v0.11.0 // indirect
|
||||||
golang.org/x/time v0.8.0 // indirect
|
golang.org/x/tools v0.36.0 // indirect
|
||||||
golang.org/x/tools v0.30.0 // indirect
|
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||||
howett.net/plist v1.0.0 // indirect
|
howett.net/plist v1.0.0 // indirect
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/cli/cli/config"
|
"github.com/docker/cli/cli/config"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types/build"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
"github.com/docker/docker/pkg/archive"
|
"github.com/docker/docker/pkg/archive"
|
||||||
@@ -76,7 +76,8 @@ func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, ser
|
|||||||
return "", fmt.Errorf("service %s has no build configuration", service.Name)
|
return "", fmt.Errorf("service %s has no build configuration", service.Name)
|
||||||
}
|
}
|
||||||
if service.Image == "" {
|
if service.Image == "" {
|
||||||
return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet", service.Name)
|
return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet",
|
||||||
|
service.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildContextPath := service.Build.Context
|
buildContextPath := service.Build.Context
|
||||||
@@ -88,7 +89,7 @@ func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, ser
|
|||||||
return "", fmt.Errorf("failed to create build context for service %s: %w", service.Name, err)
|
return "", fmt.Errorf("failed to create build context for service %s: %w", service.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildOptions := types.ImageBuildOptions{
|
buildOptions := build.ImageBuildOptions{
|
||||||
// TODO: Support Dockerfiles outside the build context
|
// TODO: Support Dockerfiles outside the build context
|
||||||
// See https://github.com/docker/compose/blob/cf89fd1aa1328d5af77658ccc5a1e1b29981ae80/pkg/compose/build_classic.go#L92
|
// See https://github.com/docker/compose/blob/cf89fd1aa1328d5af77658ccc5a1e1b29981ae80/pkg/compose/build_classic.go#L92
|
||||||
Dockerfile: service.Build.Dockerfile,
|
Dockerfile: service.Build.Dockerfile,
|
||||||
|
|||||||
+39
-43
@@ -2,7 +2,6 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
@@ -10,7 +9,6 @@ import (
|
|||||||
|
|
||||||
"github.com/docker/cli/cli/streams"
|
"github.com/docker/cli/cli/streams"
|
||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"github.com/psviderski/uncloud/internal/fs"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/sshexec"
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
@@ -73,8 +71,18 @@ func (cli *CLI) SetCurrentContext(name string) error {
|
|||||||
// ConnectCluster connects to a cluster using the given context name or the current context if not specified.
|
// ConnectCluster connects to a cluster using the given context name or the current context if not specified.
|
||||||
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
// 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, contextName string) (*client.Client, error) {
|
||||||
|
return cli.ConnectClusterWithOptions(ctx, contextName, ConnectOptions{
|
||||||
|
// Default to showing progress for CLI usage.
|
||||||
|
ShowProgress: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectClusterWithOptions connects to a cluster using the given context name and options.
|
||||||
|
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||||
|
// 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, contextName string, opts ConnectOptions) (*client.Client, error) {
|
||||||
if cli.conn != nil {
|
if cli.conn != nil {
|
||||||
return connectCluster(ctx, *cli.conn)
|
return ConnectCluster(ctx, *cli.conn, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cli.Config.Contexts) == 0 {
|
if len(cli.Config.Contexts) == 0 {
|
||||||
@@ -116,38 +124,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.
|
// Try each connection in order until one succeeds.
|
||||||
conn := cfg.Connections[0]
|
var lastErr error
|
||||||
|
for _, conn := range cfg.Connections {
|
||||||
c, err := connectCluster(ctx, conn)
|
c, err := ConnectCluster(ctx, conn, opts)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
return c, nil
|
||||||
}
|
|
||||||
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
|
||||||
if conn.SSH != "" {
|
|
||||||
user, host, port, err := conn.SSH.Parse()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
lastErr = err
|
||||||
|
|
||||||
sshConfig := &connector.SSHConnectorConfig{
|
|
||||||
User: user,
|
|
||||||
Host: host,
|
|
||||||
Port: port,
|
|
||||||
KeyPath: keyPath,
|
|
||||||
}
|
|
||||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
|
||||||
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
|
||||||
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errors.New("connection configuration is invalid")
|
return nil, fmt.Errorf("failed to connect to cluster context '%s': "+
|
||||||
|
"all connections (%d) in the Uncloud config (%s) failed; last error: %w",
|
||||||
|
contextName, len(cfg.Connections), cli.Config.Path(), lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
type InitClusterOptions struct {
|
type InitClusterOptions struct {
|
||||||
@@ -156,6 +146,7 @@ type InitClusterOptions struct {
|
|||||||
Network netip.Prefix
|
Network netip.Prefix
|
||||||
PublicIP *netip.Addr
|
PublicIP *netip.Addr
|
||||||
RemoteMachine *RemoteMachine
|
RemoteMachine *RemoteMachine
|
||||||
|
SkipInstall bool
|
||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +166,7 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -278,6 +269,7 @@ type AddMachineOptions struct {
|
|||||||
MachineName string
|
MachineName string
|
||||||
PublicIP *netip.Addr
|
PublicIP *netip.Addr
|
||||||
RemoteMachine *RemoteMachine
|
RemoteMachine *RemoteMachine
|
||||||
|
SkipInstall bool
|
||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +292,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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -418,15 +410,16 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
return c, machineClient, nil
|
return c, machineClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and returns
|
// provisionOrConnectRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and
|
||||||
// a machine API client to interact with the machine. The client should be closed after use by the caller.
|
// returns a machine API client to interact with the machine. The client should be closed after use by the caller.
|
||||||
// The version parameter specifies the version of the Uncloud daemon to install. If empty, the latest version is used.
|
// The version parameter specifies the version of the Uncloud daemon to install. If empty, the latest version is used.
|
||||||
|
// 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
|
// The remoteMachine.SSHKeyPath could be updated to the default SSH key path if it is not set and the SSH agent
|
||||||
// authentication fails.
|
// authentication fails.
|
||||||
func provisionRemoteMachine(
|
func provisionOrConnectRemoteMachine(
|
||||||
ctx context.Context, remoteMachine *RemoteMachine, version string,
|
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
|
||||||
) (*client.Client, error) {
|
) (*client.Client, error) {
|
||||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
|
||||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
||||||
// If the SSH connection using SSH agent fails and no key path is provided, try to use the default SSH key.
|
// If 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 == "" {
|
if err != nil && remoteMachine.KeyPath == "" {
|
||||||
@@ -441,14 +434,17 @@ func provisionRemoteMachine(
|
|||||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
exec := sshexec.NewRemote(sshClient)
|
|
||||||
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
if !skipInstall {
|
||||||
if err = provisionMachine(ctx, exec, version); err != nil {
|
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||||
return nil, fmt.Errorf("provision machine: %w", err)
|
exec := sshexec.NewRemote(sshClient)
|
||||||
|
if err = provisionMachine(ctx, exec, version); err != nil {
|
||||||
|
return nil, fmt.Errorf("provision machine: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var machineClient *client.Client
|
var machineClient *client.Client
|
||||||
if remoteMachine.User == "root" {
|
if remoteMachine.User == "root" || skipInstall {
|
||||||
// Create a machine API client over the established SSH connection to the remote machine.
|
// Create a machine API client over the established SSH connection to the remote machine.
|
||||||
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -24,6 +25,15 @@ type MachineConnection struct {
|
|||||||
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c MachineConnection) String() string {
|
||||||
|
if c.SSH != "" {
|
||||||
|
return string(c.SSH)
|
||||||
|
} else if c.TCP != nil && c.TCP.IsValid() {
|
||||||
|
return fmt.Sprintf("tcp://%s", c.TCP)
|
||||||
|
}
|
||||||
|
return "unknown connection"
|
||||||
|
}
|
||||||
|
|
||||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||||
// The default user "root" and port 22 can be omitted.
|
// The default user "root" and port 22 can be omitted.
|
||||||
type SSHDestination string
|
type SSHDestination string
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("connection configuration is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,11 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
|
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
|
||||||
@@ -21,3 +25,12 @@ func ExpandCommaSeparatedValues(values []string) []string {
|
|||||||
|
|
||||||
return expanded
|
return expanded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BindEnvToFlag assigns the value of an environment variable to the given command flag if the flag has not been set.
|
||||||
|
func BindEnvToFlag(cmd *cobra.Command, flagName, envVar string) {
|
||||||
|
if value := os.Getenv(envVar); value != "" && !cmd.Flags().Changed(flagName) {
|
||||||
|
if err := cmd.Flags().Set(flagName, value); err != nil {
|
||||||
|
log.Fatalf("Failed to bind environment variable '%s' to flag '%s': %v", envVar, flagName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-1
@@ -1,6 +1,11 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import "github.com/charmbracelet/huh"
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
func Confirm() (bool, error) {
|
func Confirm() (bool, error) {
|
||||||
var confirmed bool
|
var confirmed bool
|
||||||
@@ -21,3 +26,13 @@ func Confirm() (bool, error) {
|
|||||||
|
|
||||||
return confirmed, nil
|
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()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import (
|
|||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
*client.Client
|
||||||
|
}
|
||||||
|
|
||||||
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
||||||
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
|
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
|
||||||
// Retry to ping the Docker daemon until it's ready or the context is canceled.
|
// Retry to ping the Docker daemon until it's ready or the context is canceled.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ service Docker {
|
|||||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
// Docker auth credentials if necessary.
|
// Docker auth credentials if necessary.
|
||||||
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
||||||
|
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
|
||||||
|
|
||||||
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
|
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
|
||||||
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
|
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
|
||||||
@@ -113,7 +114,7 @@ message InspectImageResponse {
|
|||||||
|
|
||||||
message Image {
|
message Image {
|
||||||
Metadata metadata = 1;
|
Metadata metadata = 1;
|
||||||
// JSON serialised types.ImageInspect.
|
// JSON serialised image.InspectResponse.
|
||||||
bytes image = 2;
|
bytes image = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +135,24 @@ message RemoteImage {
|
|||||||
bytes manifest = 3;
|
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 {
|
message CreateVolumeRequest {
|
||||||
// JSON serialised volume.CreateOptions.
|
// JSON serialised volume.CreateOptions.
|
||||||
bytes options = 1;
|
bytes options = 1;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const (
|
|||||||
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
||||||
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
||||||
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
||||||
|
Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
|
||||||
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
|
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
|
||||||
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
|
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
|
||||||
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
|
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
|
||||||
@@ -53,6 +54,7 @@ type DockerClient interface {
|
|||||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
// Docker auth credentials if necessary.
|
// Docker auth credentials if necessary.
|
||||||
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
|
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)
|
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
|
||||||
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
|
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
|
||||||
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
@@ -169,6 +171,16 @@ func (c *dockerClient) InspectRemoteImage(ctx context.Context, in *InspectRemote
|
|||||||
return out, nil
|
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) {
|
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(CreateVolumeResponse)
|
out := new(CreateVolumeResponse)
|
||||||
@@ -254,6 +266,7 @@ type DockerServer interface {
|
|||||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
// Docker auth credentials if necessary.
|
// Docker auth credentials if necessary.
|
||||||
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
||||||
|
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
|
||||||
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
|
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
|
||||||
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
|
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
|
||||||
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
|
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
|
||||||
@@ -298,6 +311,9 @@ func (UnimplementedDockerServer) InspectImage(context.Context, *InspectImageRequ
|
|||||||
func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) {
|
func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method InspectRemoteImage not implemented")
|
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) {
|
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
|
||||||
}
|
}
|
||||||
@@ -495,6 +511,24 @@ func _Docker_InspectRemoteImage_Handler(srv interface{}, ctx context.Context, de
|
|||||||
return interceptor(ctx, in, info, handler)
|
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) {
|
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(CreateVolumeRequest)
|
in := new(CreateVolumeRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -660,6 +694,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "InspectRemoteImage",
|
MethodName: "InspectRemoteImage",
|
||||||
Handler: _Docker_InspectRemoteImage_Handler,
|
Handler: _Docker_InspectRemoteImage_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListImages",
|
||||||
|
Handler: _Docker_ListImages_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "CreateVolume",
|
MethodName: "CreateVolume",
|
||||||
Handler: _Docker_CreateVolume_Handler,
|
Handler: _Docker_CreateVolume_Handler,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
@@ -848,15 +847,15 @@ func newContainerRecordWithCaddyConfig(serviceName, ip, caddyConfig, machineID s
|
|||||||
return store.ContainerRecord{
|
return store.ContainerRecord{
|
||||||
Container: api.ServiceContainer{
|
Container: api.ServiceContainer{
|
||||||
Container: api.Container{
|
Container: api.Container{
|
||||||
ContainerJSON: types.ContainerJSON{
|
InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
ID: serviceName + "-" + ip, // Add ID for stable sorting
|
ID: serviceName + "-" + ip, // Add ID for stable sorting
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
Created: created.UTC().Format(time.RFC3339Nano),
|
Created: created.UTC().Format(time.RFC3339Nano),
|
||||||
},
|
},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &container.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
docker.NetworkName: {
|
docker.NetworkName: {
|
||||||
IPAddress: ip,
|
IPAddress: ip,
|
||||||
@@ -975,15 +974,15 @@ func newContainerRecordWithPorts(serviceName, ip string, ports []string, machine
|
|||||||
return store.ContainerRecord{
|
return store.ContainerRecord{
|
||||||
Container: api.ServiceContainer{
|
Container: api.ServiceContainer{
|
||||||
Container: api.Container{
|
Container: api.Container{
|
||||||
ContainerJSON: types.ContainerJSON{
|
InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
ID: serviceName + "-" + ip, // Add ID for stable sorting
|
ID: serviceName + "-" + ip, // Add ID for stable sorting
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
Created: time.Now().UTC().Format(time.RFC3339Nano),
|
Created: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
},
|
},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &container.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
docker.NetworkName: {
|
docker.NetworkName: {
|
||||||
IPAddress: ip,
|
IPAddress: ip,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
@@ -334,13 +333,13 @@ func TestGenerateJSONConfig(t *testing.T) {
|
|||||||
|
|
||||||
func newContainer(ip string, ports ...string) api.ServiceContainer {
|
func newContainer(ip string, ports ...string) api.ServiceContainer {
|
||||||
portsLabel := strings.Join(ports, ",")
|
portsLabel := strings.Join(ports, ",")
|
||||||
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &container.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
docker.NetworkName: {
|
docker.NetworkName: {
|
||||||
IPAddress: ip,
|
IPAddress: ip,
|
||||||
@@ -357,13 +356,13 @@ func newContainer(ip string, ports ...string) api.ServiceContainer {
|
|||||||
|
|
||||||
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
|
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
|
||||||
portsLabel := strings.Join(ports, ",")
|
portsLabel := strings.Join(ports, ",")
|
||||||
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &container.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
"other-network": {
|
"other-network": {
|
||||||
IPAddress: "172.17.0.2",
|
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()
|
caddyfile, modifiedAt, err := s.service.Caddyfile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
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{
|
return &pb.GetCaddyConfigResponse{
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/machine/firewall"
|
"github.com/psviderski/uncloud/internal/machine/firewall"
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"github.com/psviderski/unregistry"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
@@ -45,6 +46,8 @@ type clusterController struct {
|
|||||||
// dnsServer is the embedded internal DNS server for the cluster listening on the machine IP.
|
// dnsServer is the embedded internal DNS server for the cluster listening on the machine IP.
|
||||||
dnsServer *dns.Server
|
dnsServer *dns.Server
|
||||||
dnsResolver *dns.ClusterResolver
|
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 is a channel that is closed when the controller is stopped.
|
||||||
stopped chan struct{}
|
stopped chan struct{}
|
||||||
@@ -60,6 +63,7 @@ func newClusterController(
|
|||||||
caddyfileCtrl *caddyconfig.Controller,
|
caddyfileCtrl *caddyconfig.Controller,
|
||||||
dnsServer *dns.Server,
|
dnsServer *dns.Server,
|
||||||
dnsResolver *dns.ClusterResolver,
|
dnsResolver *dns.ClusterResolver,
|
||||||
|
unregistry *unregistry.Registry,
|
||||||
) (*clusterController, error) {
|
) (*clusterController, error) {
|
||||||
slog.Info("Starting WireGuard network.")
|
slog.Info("Starting WireGuard network.")
|
||||||
wgnet, err := network.NewWireGuardNetwork()
|
wgnet, err := network.NewWireGuardNetwork()
|
||||||
@@ -80,6 +84,7 @@ func newClusterController(
|
|||||||
caddyconfigCtrl: caddyfileCtrl,
|
caddyconfigCtrl: caddyfileCtrl,
|
||||||
dnsServer: dnsServer,
|
dnsServer: dnsServer,
|
||||||
dnsResolver: dnsResolver,
|
dnsResolver: dnsResolver,
|
||||||
|
unregistry: unregistry,
|
||||||
stopped: make(chan struct{}),
|
stopped: make(chan struct{}),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -87,7 +92,7 @@ func newClusterController(
|
|||||||
func (cc *clusterController) Run(ctx context.Context) error {
|
func (cc *clusterController) Run(ctx context.Context) error {
|
||||||
defer close(cc.stopped)
|
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)
|
return fmt.Errorf("configure iptables chains: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +169,7 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for endpoint changes and update the machine state accordingly.
|
// Watch for WireGuard peer endpoint changes and update the machine state accordingly.
|
||||||
errGroup.Go(func() error {
|
errGroup.Go(func() error {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -210,6 +215,16 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for the context to be done and stop the network API server.
|
// Wait for the context to be done and stop the network API server.
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
slog.Info("Stopping network API server.")
|
slog.Info("Stopping network API server.")
|
||||||
@@ -217,6 +232,19 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
|||||||
cc.server.GracefulStop()
|
cc.server.GracefulStop()
|
||||||
slog.Info("Network API server stopped.")
|
slog.Info("Network API server stopped.")
|
||||||
|
|
||||||
|
// 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.
|
// Wait for all controllers to finish.
|
||||||
err = errGroup.Wait()
|
err = errGroup.Wait()
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (c *Cluster) ReserveDomain(ctx context.Context, req *pb.ReserveDomainReques
|
|||||||
dnsClient := dns.NewClient()
|
dnsClient := dns.NewClient()
|
||||||
name, token, err := dnsClient.ReserveDomain(req.Endpoint)
|
name, token, err := dnsClient.ReserveDomain(req.Endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
domain := uncloudDNSDomain{
|
domain := uncloudDNSDomain{
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ package constants
|
|||||||
const (
|
const (
|
||||||
// MachineAPIPort is the port for the Machine API service on the management WireGuard network.
|
// MachineAPIPort is the port for the Machine API service on the management WireGuard network.
|
||||||
MachineAPIPort = 51000
|
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"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
@@ -30,7 +31,7 @@ type DockerService struct {
|
|||||||
func (s *DockerService) Start(ctx context.Context) error {
|
func (s *DockerService) Start(ctx context.Context) error {
|
||||||
_, err := s.Client.ContainerInspect(ctx, s.Name)
|
_, err := s.Client.ContainerInspect(ctx, s.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !client.IsErrNotFound(err) {
|
if !errdefs.IsNotFound(err) {
|
||||||
return fmt.Errorf("inspect container %q: %w", s.Name, err)
|
return fmt.Errorf("inspect container %q: %w", s.Name, err)
|
||||||
}
|
}
|
||||||
if err = s.startNewContainer(ctx); err != nil {
|
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 {
|
func (s *DockerService) startNewContainer(ctx context.Context) error {
|
||||||
_, err := s.Client.ContainerCreate(ctx, s.containerConfig(), s.hostConfig(), nil, nil, s.Name)
|
_, err := s.Client.ContainerCreate(ctx, s.containerConfig(), s.hostConfig(), nil, nil, s.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !client.IsErrNotFound(err) {
|
if !errdefs.IsNotFound(err) {
|
||||||
return fmt.Errorf("create container: %w", 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)
|
newServiceIPs[ctr.ServiceName()] = append(newServiceIPs[ctr.ServiceName()], ip)
|
||||||
// Also add the service ID as a valid lookup.
|
// Also add the service ID as a valid lookup.
|
||||||
newServiceIPs[ctr.ServiceID()] = append(newServiceIPs[ctr.ServiceID()], ip)
|
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++
|
containersCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +104,7 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
|||||||
r.serviceIPs = newServiceIPs
|
r.serviceIPs = newServiceIPs
|
||||||
r.mu.Unlock()
|
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.
|
// Resolve returns IP addresses of the service containers.
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
@@ -16,6 +15,7 @@ import (
|
|||||||
"github.com/docker/docker/pkg/jsonmessage"
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
regtypes "github.com/google/go-containerregistry/pkg/v1/types"
|
regtypes "github.com/google/go-containerregistry/pkg/v1/types"
|
||||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
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/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc"
|
"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.
|
// 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 {
|
type Client struct {
|
||||||
conn *grpc.ClientConn
|
conn *grpc.ClientConn
|
||||||
grpcClient pb.DockerClient
|
GRPCClient pb.DockerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new Docker gRPC client with the provided gRPC connection.
|
// NewClient creates a new Docker gRPC client with the provided gRPC connection.
|
||||||
func NewClient(conn *grpc.ClientConn) *Client {
|
func NewClient(conn *grpc.ClientConn) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
conn: conn,
|
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)
|
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,
|
Config: configBytes,
|
||||||
HostConfig: hostConfigBytes,
|
HostConfig: hostConfigBytes,
|
||||||
NetworkConfig: networkingConfigBytes,
|
NetworkConfig: networkingConfigBytes,
|
||||||
@@ -91,10 +93,10 @@ func (c *Client) CreateContainer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InspectContainer returns the container information for the given container ID.
|
// InspectContainer returns the container information for the given container ID.
|
||||||
func (c *Client) InspectContainer(ctx context.Context, id string) (types.ContainerJSON, error) {
|
func (c *Client) InspectContainer(ctx context.Context, id string) (container.InspectResponse, error) {
|
||||||
var resp types.ContainerJSON
|
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 err != nil {
|
||||||
if status.Convert(err).Code() == codes.NotFound {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
return resp, errdefs.NotFound(err)
|
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)
|
return fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.grpcClient.StartContainer(ctx, &pb.StartContainerRequest{
|
_, err = c.GRPCClient.StartContainer(ctx, &pb.StartContainerRequest{
|
||||||
Id: id,
|
Id: id,
|
||||||
Options: optsBytes,
|
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)
|
return fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.grpcClient.StopContainer(ctx, &pb.StopContainerRequest{
|
_, err = c.GRPCClient.StopContainer(ctx, &pb.StopContainerRequest{
|
||||||
Id: id,
|
Id: id,
|
||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
@@ -148,7 +150,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
|
|||||||
|
|
||||||
type MachineContainers struct {
|
type MachineContainers struct {
|
||||||
Metadata *pb.Metadata
|
Metadata *pb.Metadata
|
||||||
Containers []types.ContainerJSON
|
Containers []container.InspectResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions) ([]MachineContainers, error) {
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
return fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.grpcClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
|
_, err = c.GRPCClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
|
||||||
Id: id,
|
Id: id,
|
||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
@@ -205,23 +207,18 @@ type PullOptions struct {
|
|||||||
Platform string
|
Platform string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PullImageMessage struct {
|
func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions) (<-chan docker.PullPushImageMessage, error) {
|
||||||
Message jsonmessage.JSONMessage
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions) (<-chan PullImageMessage, error) {
|
|
||||||
optsBytes, err := json.Marshal(opts)
|
optsBytes, err := json.Marshal(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal options: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ch := make(chan PullImageMessage)
|
ch := make(chan docker.PullPushImageMessage)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(ch)
|
defer close(ch)
|
||||||
@@ -232,16 +229,16 @@ func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- PullImageMessage{Err: err}
|
ch <- docker.PullPushImageMessage{Err: err}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var jm jsonmessage.JSONMessage
|
var jm jsonmessage.JSONMessage
|
||||||
if err = json.Unmarshal(msg.Message, &jm); err != nil {
|
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
|
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.
|
// 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) {
|
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 err != nil {
|
||||||
// If the request was sent to only one machine, err is an actual error from the machine.
|
// If the request was sent to only one machine, err is an actual error from the machine.
|
||||||
if status.Convert(err).Code() == codes.NotFound {
|
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
|
// 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.
|
// contain an IndexManifest or an ImageManifest.
|
||||||
func (c *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
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 {
|
if err != nil {
|
||||||
return vol, err
|
return vol, err
|
||||||
}
|
}
|
||||||
@@ -377,20 +374,18 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
|
|||||||
return vol, nil
|
return vol, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MachineVolumes represents a volume list response from a machine.
|
|
||||||
type MachineVolumes struct {
|
type MachineVolumes struct {
|
||||||
Metadata *pb.Metadata
|
Metadata *pb.Metadata
|
||||||
Response volume.ListResponse
|
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) {
|
func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]MachineVolumes, error) {
|
||||||
optsBytes, err := json.Marshal(opts)
|
optsBytes, err := json.Marshal(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal options: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// RemoveVolume removes a volume with the given ID.
|
||||||
func (c *Client) RemoveVolume(ctx context.Context, id string, force bool) error {
|
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,
|
Id: id,
|
||||||
Force: force,
|
Force: force,
|
||||||
})
|
})
|
||||||
@@ -435,7 +430,7 @@ func (c *Client) CreateServiceContainer(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, fmt.Errorf("marshal service spec: %w", err)
|
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,
|
ServiceId: serviceID,
|
||||||
ServiceSpec: specBytes,
|
ServiceSpec: specBytes,
|
||||||
ContainerName: containerName,
|
ContainerName: containerName,
|
||||||
@@ -458,7 +453,7 @@ func (c *Client) CreateServiceContainer(
|
|||||||
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
|
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
|
||||||
var resp api.ServiceContainer
|
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 err != nil {
|
||||||
if status.Convert(err).Code() == codes.NotFound {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
return resp, errdefs.NotFound(err)
|
return resp, errdefs.NotFound(err)
|
||||||
@@ -491,7 +486,7 @@ func (c *Client) ListServiceContainers(
|
|||||||
return nil, fmt.Errorf("marshal options: %w", err)
|
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,
|
ServiceId: serviceNameOrID,
|
||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
@@ -530,7 +525,7 @@ func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts con
|
|||||||
return fmt.Errorf("marshal options: %w", err)
|
return fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.grpcClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
_, err = c.GRPCClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
||||||
Id: id,
|
Id: id,
|
||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
dockercontainer "github.com/docker/docker/api/types/container"
|
dockercontainer "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
dnetwork "github.com/docker/docker/api/types/network"
|
dnetwork "github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/client"
|
|
||||||
"github.com/docker/docker/libnetwork/iptables"
|
"github.com/docker/docker/libnetwork/iptables"
|
||||||
"github.com/psviderski/uncloud/internal/machine/dns"
|
"github.com/psviderski/uncloud/internal/machine/dns"
|
||||||
"github.com/psviderski/uncloud/internal/machine/firewall"
|
"github.com/psviderski/uncloud/internal/machine/firewall"
|
||||||
@@ -27,7 +27,7 @@ func (c *Controller) EnsureUncloudNetwork(ctx context.Context, subnet netip.Pref
|
|||||||
needsCreation := false
|
needsCreation := false
|
||||||
nw, err := c.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
nw, err := c.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !client.IsErrNotFound(err) {
|
if !errdefs.IsNotFound(err) {
|
||||||
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
||||||
}
|
}
|
||||||
needsCreation = true
|
needsCreation = true
|
||||||
@@ -187,7 +187,7 @@ func (c *Controller) Cleanup() error {
|
|||||||
|
|
||||||
for _, ctr := range containers {
|
for _, ctr := range containers {
|
||||||
err = c.client.ContainerStop(ctx, ctr.ID, dockercontainer.StopOptions{})
|
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))
|
errs = append(errs, fmt.Errorf("stop container '%s': %w", ctr.ID, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ func (c *Controller) Cleanup() error {
|
|||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
removed++
|
removed++
|
||||||
} else if !client.IsErrNotFound(err) {
|
} else if !errdefs.IsNotFound(err) {
|
||||||
errs = append(errs, fmt.Errorf("remove container '%s': %w", ctr.ID, 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 {
|
if err = c.client.NetworkRemove(ctx, NetworkName); err == nil {
|
||||||
slog.Info("Docker network removed.", "name", NetworkName)
|
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))
|
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))
|
errs = append(errs, fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
package docker
|
package docker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
dockercommand "github.com/docker/cli/cli/command"
|
dockercommand "github.com/docker/cli/cli/command"
|
||||||
dockerconfig "github.com/docker/cli/cli/config"
|
dockerconfig "github.com/docker/cli/cli/config"
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
@@ -53,41 +58,35 @@ type Server struct {
|
|||||||
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
// 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).
|
// address if the address is unknown (e.g. when the machine is not initialised yet).
|
||||||
internalDNSIP func() netip.Addr
|
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 is a function that returns true if the Docker network is ready for containers.
|
||||||
networkReady func() bool
|
networkReady func() bool
|
||||||
// waitForNetworkReady is a function that waits for the Docker network to be ready for containers.
|
// waitForNetworkReady is a function that waits for the Docker network to be ready for containers.
|
||||||
waitForNetworkReady func(ctx context.Context) error
|
waitForNetworkReady func(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerOption configures the Docker server.
|
type ServerOptions struct {
|
||||||
type ServerOption func(*Server)
|
// 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
|
||||||
// WithNetworkReady sets the network readiness check function.
|
// API server but in this case we should probably fail until the cluster is initialised.
|
||||||
func WithNetworkReady(networkReady func() bool) ServerOption {
|
NetworkReady func() bool
|
||||||
return func(s *Server) {
|
WaitForNetworkReady func(ctx context.Context) error
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new Docker gRPC server with the provided Docker service.
|
// 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{
|
s := &Server{
|
||||||
client: service.Client,
|
client: service.Client,
|
||||||
service: service,
|
service: service,
|
||||||
db: db,
|
db: db,
|
||||||
internalDNSIP: internalDNSIP,
|
internalDNSIP: internalDNSIP,
|
||||||
|
machineID: machineID,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range opts {
|
s.networkReady = opts.NetworkReady
|
||||||
opt(s)
|
s.waitForNetworkReady = opts.WaitForNetworkReady
|
||||||
}
|
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -115,10 +114,10 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
|
|||||||
|
|
||||||
resp, err := s.client.ContainerCreate(ctx, &config, &hostConfig, &networkConfig, &platform, req.Name)
|
resp, err := s.client.ContainerCreate(ctx, &config, &hostConfig, &networkConfig, &platform, req.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(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())
|
||||||
}
|
}
|
||||||
|
|
||||||
respBytes, err := json.Marshal(resp)
|
respBytes, err := json.Marshal(resp)
|
||||||
@@ -133,10 +132,10 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
|
|||||||
func (s *Server) InspectContainer(ctx context.Context, req *pb.InspectContainerRequest) (*pb.InspectContainerResponse, error) {
|
func (s *Server) InspectContainer(ctx context.Context, req *pb.InspectContainerRequest) (*pb.InspectContainerResponse, error) {
|
||||||
resp, err := s.client.ContainerInspect(ctx, req.Id)
|
resp, err := s.client.ContainerInspect(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(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())
|
||||||
}
|
}
|
||||||
|
|
||||||
respBytes, err := json.Marshal(resp)
|
respBytes, err := json.Marshal(resp)
|
||||||
@@ -166,10 +165,10 @@ func (s *Server) StartContainer(ctx context.Context, req *pb.StartContainerReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.client.ContainerStart(ctx, req.Id, opts); err != nil {
|
if err := s.client.ContainerStart(ctx, req.Id, opts); err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(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 &emptypb.Empty{}, nil
|
return &emptypb.Empty{}, nil
|
||||||
@@ -185,10 +184,10 @@ func (s *Server) StopContainer(ctx context.Context, req *pb.StopContainerRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.client.ContainerStop(ctx, req.Id, opts); err != nil {
|
if err := s.client.ContainerStop(ctx, req.Id, opts); err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(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 &emptypb.Empty{}, nil
|
return &emptypb.Empty{}, nil
|
||||||
@@ -218,13 +217,13 @@ func (s *Server) ListContainers(ctx context.Context, req *pb.ListContainersReque
|
|||||||
|
|
||||||
containerSummaries, err := s.client.ContainerList(ctx, opts)
|
containerSummaries, err := s.client.ContainerList(ctx, opts)
|
||||||
if err != nil {
|
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 {
|
for _, cs := range containerSummaries {
|
||||||
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
// The listed container may have been removed while we were inspecting other containers.
|
// The listed container may have been removed while we were inspecting other containers.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -257,10 +256,10 @@ func (s *Server) RemoveContainer(ctx context.Context, req *pb.RemoveContainerReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.client.ContainerRemove(ctx, req.Id, opts); err != nil {
|
if err := s.client.ContainerRemove(ctx, req.Id, opts); err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(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 &emptypb.Empty{}, nil
|
return &emptypb.Empty{}, nil
|
||||||
@@ -286,7 +285,7 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
|
|||||||
|
|
||||||
respBody, err := s.client.ImagePull(ctx, req.Image, opts)
|
respBody, err := s.client.ImagePull(ctx, req.Image, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status.Errorf(codes.Internal, err.Error())
|
return status.Error(codes.Internal, err.Error())
|
||||||
}
|
}
|
||||||
defer respBody.Close()
|
defer respBody.Close()
|
||||||
|
|
||||||
@@ -317,19 +316,19 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
|
|||||||
case err = <-errCh:
|
case err = <-errCh:
|
||||||
return err
|
return err
|
||||||
case <-ctx.Done():
|
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.
|
// InspectImage returns the image information for the given image ID.
|
||||||
func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest) (*pb.InspectImageResponse, error) {
|
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 err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(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())
|
||||||
}
|
}
|
||||||
|
|
||||||
respBytes, err := json.Marshal(resp)
|
respBytes, err := json.Marshal(resp)
|
||||||
@@ -385,6 +384,49 @@ func (s *Server) InspectRemoteImage(
|
|||||||
}, nil
|
}, 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.
|
// CreateVolume creates a new volume with the given options.
|
||||||
func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest) (*pb.CreateVolumeResponse, error) {
|
func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest) (*pb.CreateVolumeResponse, error) {
|
||||||
var opts volume.CreateOptions
|
var opts volume.CreateOptions
|
||||||
@@ -456,7 +498,7 @@ func (s *Server) ListVolumes(ctx context.Context, req *pb.ListVolumesRequest) (*
|
|||||||
// RemoveVolume removes a volume with the given ID.
|
// RemoveVolume removes a volume with the given ID.
|
||||||
func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest) (*emptypb.Empty, error) {
|
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 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.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -492,9 +534,21 @@ func (s *Server) CreateServiceContainer(
|
|||||||
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
|
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{
|
config := &container.Config{
|
||||||
Cmd: spec.Container.Command,
|
Cmd: spec.Container.Command,
|
||||||
Env: spec.Container.Env.ToSlice(),
|
Env: envVars.ToSlice(),
|
||||||
Entrypoint: spec.Container.Entrypoint,
|
Entrypoint: spec.Container.Entrypoint,
|
||||||
Hostname: containerName,
|
Hostname: containerName,
|
||||||
Image: spec.Container.Image,
|
Image: spec.Container.Image,
|
||||||
@@ -590,12 +644,19 @@ func (s *Server) CreateServiceContainer(
|
|||||||
|
|
||||||
resp, err := s.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, containerName)
|
resp, err := s.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, containerName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, 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)
|
respBytes, err := json.Marshal(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
||||||
@@ -672,6 +733,122 @@ func ToDockerMounts(volumes []api.VolumeSpec, mounts []api.VolumeMount) ([]mount
|
|||||||
return dockerMounts, nil
|
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.
|
||||||
|
func (s *Server) copyContentToContainer(ctx context.Context, containerID string, content []byte, targetPath string, uid *uint64, gid *uint64, fileMode os.FileMode) error {
|
||||||
|
// Create a tar archive containing the file
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tw := tar.NewWriter(&buf)
|
||||||
|
|
||||||
|
// Create tar header
|
||||||
|
header := &tar.Header{
|
||||||
|
Name: filepath.Base(targetPath),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the tar archive to the container
|
||||||
|
targetDir := filepath.Dir(targetPath)
|
||||||
|
if targetDir == "." {
|
||||||
|
targetDir = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.client.CopyToContainer(
|
||||||
|
ctx,
|
||||||
|
containerID,
|
||||||
|
targetDir,
|
||||||
|
&buf,
|
||||||
|
container.CopyToContainerOptions{CopyUIDGID: true},
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("copy to container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
||||||
if opts == nil {
|
if opts == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -703,7 +880,7 @@ func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mo
|
|||||||
|
|
||||||
// TODO: non-local volume drivers should likely be handled differently (needs proper investigation).
|
// TODO: non-local volume drivers should likely be handled differently (needs proper investigation).
|
||||||
if _, err := s.client.VolumeInspect(ctx, m.Source); err != nil {
|
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.NotFound, "volume '%s' not found", m.Source)
|
||||||
}
|
}
|
||||||
return status.Errorf(codes.Internal, "inspect volume '%s': %v", m.Source, err.Error())
|
return status.Errorf(codes.Internal, "inspect volume '%s': %v", m.Source, err.Error())
|
||||||
@@ -723,10 +900,10 @@ func (s *Server) InspectServiceContainer(
|
|||||||
) (*pb.ServiceContainer, error) {
|
) (*pb.ServiceContainer, error) {
|
||||||
serviceCtr, err := s.service.InspectServiceContainer(ctx, req.Id)
|
serviceCtr, err := s.service.InspectServiceContainer(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(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())
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrBytes, err := json.Marshal(serviceCtr.Container)
|
ctrBytes, err := json.Marshal(serviceCtr.Container)
|
||||||
@@ -813,7 +990,7 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
|
|||||||
if !fullDockerIDRegex.MatchString(req.Id) {
|
if !fullDockerIDRegex.MatchString(req.Id) {
|
||||||
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
|||||||
@@ -7,19 +7,25 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"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
|
// Service provides higher-level Docker operations that extends Docker API with Uncloud-specific data
|
||||||
// from the machine database.
|
// from the machine database.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
// Client is a Docker client for managing Docker resources.
|
||||||
Client *client.Client
|
Client *client.Client
|
||||||
db *sqlx.DB
|
// db is a connection to the machine database.
|
||||||
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new Docker service instance.
|
// NewService creates a new Docker service instance.
|
||||||
@@ -43,7 +49,7 @@ func (s *Service) InspectServiceContainer(ctx context.Context, nameOrID string)
|
|||||||
return serviceCtr, fmt.Errorf("container '%s' is not managed by Uncloud", nameOrID)
|
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.
|
// Retrieve ServiceSpec from the machine database.
|
||||||
var specBytes []byte
|
var specBytes []byte
|
||||||
@@ -101,3 +107,46 @@ func (s *Service) ListServiceContainers(
|
|||||||
|
|
||||||
return containers, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package firewall
|
package firewall
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
)
|
||||||
|
|
||||||
// ConfigureIptablesChains is a stub for Darwin.
|
// ConfigureIptablesChains is a stub for Darwin.
|
||||||
func ConfigureIptablesChains() error {
|
func ConfigureIptablesChains(machineIP netip.Addr) error {
|
||||||
return fmt.Errorf("not supported on Darwin")
|
return fmt.Errorf("not supported on Darwin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package firewall
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ConfigureIptablesChains sets up custom iptables chains and initial firewall rules for Uncloud networking.
|
// 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 {
|
if err := createIptablesChains(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -27,10 +28,24 @@ func ConfigureIptablesChains() error {
|
|||||||
ipt6 := iptables.GetIptable(iptables.IPv6)
|
ipt6 := iptables.GetIptable(iptables.IPv6)
|
||||||
|
|
||||||
// Allow WireGuard traffic to the machine.
|
// Allow WireGuard traffic to the machine.
|
||||||
acceptWireGuardRule := []string{"-p", "udp", "--dport", strconv.Itoa(network.WireGuardPort), "-j", "ACCEPT"}
|
acceptWireGuardRule := []string{
|
||||||
err := ipt4.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, acceptWireGuardRule)
|
"-p", "udp",
|
||||||
if err != nil {
|
"--dport", strconv.Itoa(network.WireGuardPort),
|
||||||
return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(acceptWireGuardRule, " "), err)
|
"-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.
|
// Allow cluster machines to access Machine API via the management IPv6 WireGuard network.
|
||||||
@@ -50,7 +65,7 @@ func ConfigureIptablesChains() error {
|
|||||||
"-j", "ACCEPT",
|
"-j", "ACCEPT",
|
||||||
}
|
}
|
||||||
for _, rule := range [][]string{acceptMachineAPIRule, acceptCorrosionGossipRule} {
|
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)
|
return fmt.Errorf("insert ip6tables rule '%s': %w", strings.Join(rule, " "), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"github.com/psviderski/unregistry"
|
||||||
"github.com/siderolabs/grpc-proxy/proxy"
|
"github.com/siderolabs/grpc-proxy/proxy"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -63,6 +64,8 @@ type Config struct {
|
|||||||
|
|
||||||
// DockerClient manages system and user containers using the local Docker daemon.
|
// DockerClient manages system and user containers using the local Docker daemon.
|
||||||
DockerClient *client.Client
|
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
|
// 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.
|
// for routing external traffic to service containers across the internal network. Default is DataDir/caddy.
|
||||||
@@ -93,6 +96,26 @@ func (c *Config) SetDefaults() (*Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.DockerClient = cli
|
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 == "" {
|
if cfg.CorrosionDir == "" {
|
||||||
cfg.CorrosionDir = filepath.Join(cfg.DataDir, "corrosion")
|
cfg.CorrosionDir = filepath.Join(cfg.DataDir, "corrosion")
|
||||||
@@ -231,7 +254,6 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init machine database: %w", err)
|
return nil, fmt.Errorf("init machine database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dockerService := machinedocker.NewService(config.DockerClient, db)
|
dockerService := machinedocker.NewService(config.DockerClient, db)
|
||||||
|
|
||||||
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
||||||
@@ -260,9 +282,14 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
internalDNSIP := func() netip.Addr {
|
internalDNSIP := func() netip.Addr {
|
||||||
return m.IP()
|
return m.IP()
|
||||||
}
|
}
|
||||||
m.dockerServer = machinedocker.NewServer(dockerService, db, internalDNSIP,
|
// Machine ID will only be available after the machine is initialised as a cluster member so wrap it in a function.
|
||||||
machinedocker.WithNetworkReady(m.IsNetworkReady),
|
machineID := func() string {
|
||||||
machinedocker.WithWaitForNetworkReady(m.WaitForNetworkReady))
|
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))
|
caddyServer := caddyconfig.NewServer(caddyconfig.NewService(config.CaddyConfigDir))
|
||||||
m.localMachineServer = newGRPCServer(m, c, m.dockerServer, caddyServer)
|
m.localMachineServer = newGRPCServer(m, c, m.dockerServer, caddyServer)
|
||||||
|
|
||||||
@@ -408,6 +435,33 @@ func (m *Machine) Run(ctx context.Context) error {
|
|||||||
return fmt.Errorf("create embedded DNS server: %w", err)
|
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.mu.Lock()
|
||||||
m.clusterCtrl, err = newClusterController(
|
m.clusterCtrl, err = newClusterController(
|
||||||
m.state,
|
m.state,
|
||||||
@@ -419,6 +473,7 @@ func (m *Machine) Run(ctx context.Context) error {
|
|||||||
caddyconfigCtrl,
|
caddyconfigCtrl,
|
||||||
dnsServer,
|
dnsServer,
|
||||||
dnsResolver,
|
dnsResolver,
|
||||||
|
unreg,
|
||||||
)
|
)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ CREATE TABLE machines
|
|||||||
CREATE TABLE containers
|
CREATE TABLE containers
|
||||||
(
|
(
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
-- container is a JSON-serialized api.Container struct.
|
-- container is a JSON-serialized api.ServiceContainer struct.
|
||||||
container TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(container)),
|
container TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(container)),
|
||||||
machine_id TEXT NOT NULL DEFAULT '',
|
machine_id TEXT NOT NULL DEFAULT '',
|
||||||
service_id TEXT AS (json_extract(container, '$.Config.Labels."uncloud.service.id"')),
|
service_id TEXT AS (json_extract(container, '$.Config.Labels."uncloud.service.id"')),
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Proxy proxies local connections to a remote TCP address optionally using a custom dialer.
|
||||||
|
type Proxy struct {
|
||||||
|
Listener net.Listener
|
||||||
|
RemoteAddr string
|
||||||
|
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
||||||
|
OnError func(error)
|
||||||
|
activeConns sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadliner is an interface for listeners that support setting deadlines.
|
||||||
|
type deadliner interface {
|
||||||
|
SetDeadline(t time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// halfCloser is an interface for connections that support half-close.
|
||||||
|
type halfCloser interface {
|
||||||
|
CloseWrite() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the proxy and runs until the context is canceled.
|
||||||
|
func (p *Proxy) Run(ctx context.Context) {
|
||||||
|
if p.DialContext == nil {
|
||||||
|
p.DialContext = (&net.Dialer{}).DialContext
|
||||||
|
}
|
||||||
|
|
||||||
|
defer p.Listener.Close()
|
||||||
|
|
||||||
|
// Handle incoming connections until context is canceled.
|
||||||
|
Loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
break Loop
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a deadline on the listener if supported to check context periodically.
|
||||||
|
if dl, ok := p.Listener.(deadliner); ok {
|
||||||
|
dl.SetDeadline(time.Now().Add(1 * time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := p.Listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if os.IsTimeout(err) {
|
||||||
|
// Just a timeout, continue to check context and accept again.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
break Loop
|
||||||
|
default:
|
||||||
|
if p.OnError != nil {
|
||||||
|
p.OnError(fmt.Errorf("accept local connection: %w", err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.activeConns.Add(1)
|
||||||
|
go p.handleConnection(ctx, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all connections to finish.
|
||||||
|
p.activeConns.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) handleConnection(ctx context.Context, localConn net.Conn) {
|
||||||
|
defer p.activeConns.Done()
|
||||||
|
defer localConn.Close()
|
||||||
|
|
||||||
|
// Use a separate context with timeout for dialing the remote address.
|
||||||
|
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
remoteConn, err := p.DialContext(dialCtx, "tcp", p.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
if p.OnError != nil {
|
||||||
|
p.OnError(fmt.Errorf("connect remote address '%s': %w", p.RemoteAddr, err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer remoteConn.Close()
|
||||||
|
|
||||||
|
// Bidirectional copy with proper half-close handling.
|
||||||
|
done := make(chan error, 2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(remoteConn, localConn)
|
||||||
|
// Close write half of remote connection if supported.
|
||||||
|
if hc, ok := remoteConn.(halfCloser); ok {
|
||||||
|
hc.CloseWrite()
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(localConn, remoteConn)
|
||||||
|
// Close write half of local connection if supported.
|
||||||
|
if hc, ok := localConn.(halfCloser); ok {
|
||||||
|
hc.CloseWrite()
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for both copies to complete or context cancel.
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Close connections to abort ongoing copies.
|
||||||
|
localConn.Close()
|
||||||
|
remoteConn.Close()
|
||||||
|
return
|
||||||
|
case err = <-done:
|
||||||
|
if err != nil && p.OnError != nil {
|
||||||
|
p.OnError(fmt.Errorf("data copy: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
dockerclient "github.com/docker/docker/client"
|
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/psviderski/uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
@@ -200,7 +200,7 @@ func (p *Provisioner) InspectCluster(ctx context.Context, name string) (Cluster,
|
|||||||
// Docker network name is the same as the cluster name.
|
// Docker network name is the same as the cluster name.
|
||||||
net, err := p.dockerCli.NetworkInspect(ctx, name, network.InspectOptions{})
|
net, err := p.dockerCli.NetworkInspect(ctx, name, network.InspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if dockerclient.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return c, ErrNotFound
|
return c, ErrNotFound
|
||||||
}
|
}
|
||||||
return c, fmt.Errorf("inspect Docker network '%s': %w", name, err)
|
return c, fmt.Errorf("inspect Docker network '%s': %w", name, err)
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
dockerclient "github.com/docker/docker/client"
|
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
@@ -137,7 +137,7 @@ func (p *Provisioner) createContainerWithImagePull(
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !dockerclient.IsErrNotFound(err) {
|
if !errdefs.IsNotFound(err) {
|
||||||
return resp, fmt.Errorf("create Docker container: %w", err)
|
return resp, fmt.Errorf("create Docker container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-7
@@ -3,7 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
@@ -63,21 +63,35 @@ type VolumeClient interface {
|
|||||||
func ProxyMachinesContext(
|
func ProxyMachinesContext(
|
||||||
ctx context.Context, cli MachineClient, namesOrIDs []string,
|
ctx context.Context, cli MachineClient, namesOrIDs []string,
|
||||||
) (context.Context, MachineMembersList, error) {
|
) (context.Context, MachineMembersList, error) {
|
||||||
|
// TODO: move the machine IP resolution to the proxy router to allow setting machine names and IDs in the metadata.
|
||||||
machines, err := cli.ListMachines(ctx, nil)
|
machines, err := cli.ListMachines(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("list machines: %w", err)
|
return nil, nil, fmt.Errorf("list machines: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var proxiedMachines MachineMembersList
|
var proxiedMachines MachineMembersList
|
||||||
md := metadata.New(nil)
|
var notFound []string
|
||||||
for _, m := range machines {
|
for _, nameOrID := range namesOrIDs {
|
||||||
if len(namesOrIDs) == 0 ||
|
if m := machines.FindByNameOrID(nameOrID); m != nil {
|
||||||
slices.Contains(namesOrIDs, m.Machine.Name) || slices.Contains(namesOrIDs, m.Machine.Id) {
|
|
||||||
proxiedMachines = append(proxiedMachines, m)
|
proxiedMachines = append(proxiedMachines, m)
|
||||||
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
} else {
|
||||||
md.Append("machines", machineIP.String())
|
notFound = append(notFound, nameOrID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(notFound) > 0 {
|
||||||
|
return nil, nil, fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(namesOrIDs) == 0 {
|
||||||
|
proxiedMachines = machines
|
||||||
|
}
|
||||||
|
|
||||||
|
md := metadata.New(nil)
|
||||||
|
for _, m := range proxiedMachines {
|
||||||
|
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||||
|
md.Append("machines", machineIP.String())
|
||||||
|
}
|
||||||
|
|
||||||
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
|
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Implementation of Config feature from the Compose spec
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigSpec defines a configuration object that can be mounted into containers
|
||||||
|
type ConfigSpec struct {
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Content of the config when specified inline
|
||||||
|
Content []byte `json:",omitempty"`
|
||||||
|
|
||||||
|
// Note: NOT IMPLEMENTED
|
||||||
|
// External indicates this config already exists and should not be created
|
||||||
|
// External bool `json:",omitempty"`
|
||||||
|
|
||||||
|
// Note: NOT IMPLEMENTED
|
||||||
|
// Labels for the config
|
||||||
|
// Labels map[string]string `json:",omitempty"`
|
||||||
|
|
||||||
|
// TODO: add support for "environment"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigSpec) Validate() error {
|
||||||
|
if c.Name == "" {
|
||||||
|
return fmt.Errorf("config name is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equals compares two ConfigSpec instances
|
||||||
|
func (c *ConfigSpec) Equals(other ConfigSpec) bool {
|
||||||
|
return c.Name == other.Name &&
|
||||||
|
bytes.Equal(c.Content, other.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigMount defines how a config is mounted into a container
|
||||||
|
type ConfigMount struct {
|
||||||
|
// ConfigName references a config defined in ServiceSpec.Configs by its Name field
|
||||||
|
ConfigName string
|
||||||
|
// ContainerPath is the absolute path where the config is mounted in the container
|
||||||
|
ContainerPath string `json:",omitempty"`
|
||||||
|
// Uid for the mounted config file
|
||||||
|
Uid string `json:",omitempty"`
|
||||||
|
// Gid for the mounted config file
|
||||||
|
Gid string `json:",omitempty"`
|
||||||
|
// Mode (file permissions) for the mounted config file
|
||||||
|
Mode *os.FileMode `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigMount) GetNumericUid() (*uint64, error) {
|
||||||
|
if c.Uid == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
uid, err := strconv.ParseUint(c.Uid, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Uid '%s': %w", c.Uid, err)
|
||||||
|
}
|
||||||
|
if int(uid) < 0 {
|
||||||
|
return nil, fmt.Errorf("invalid Uid '%s': value too high", c.Uid)
|
||||||
|
}
|
||||||
|
return &uid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigMount) GetNumericGid() (*uint64, error) {
|
||||||
|
if c.Gid == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
gid, err := strconv.ParseUint(c.Gid, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Gid '%s': %w", c.Gid, err)
|
||||||
|
}
|
||||||
|
if int(gid) < 0 {
|
||||||
|
return nil, fmt.Errorf("invalid Gid '%s': value too high", c.Gid)
|
||||||
|
}
|
||||||
|
return &gid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigMount) Validate() error {
|
||||||
|
if c.ConfigName == "" {
|
||||||
|
return fmt.Errorf("config mount source is required")
|
||||||
|
}
|
||||||
|
if _, err := c.GetNumericUid(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := c.GetNumericGid(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.ContainerPath != "" && !filepath.IsAbs(c.ContainerPath) {
|
||||||
|
return fmt.Errorf("container path must be absolute")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfigsAndMounts takes config specs and config mounts and validates that all mounts refer to existing specs
|
||||||
|
func ValidateConfigsAndMounts(configs []ConfigSpec, mounts []ConfigMount) error {
|
||||||
|
configMap := make(map[string]struct{})
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
if _, ok := configMap[cfg.Name]; ok {
|
||||||
|
return fmt.Errorf("duplicate config name: '%s'", cfg.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
configMap[cfg.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mount := range mounts {
|
||||||
|
if err := mount.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid config mount: %w", err)
|
||||||
|
}
|
||||||
|
if _, exists := configMap[mount.ConfigName]; !exists {
|
||||||
|
return fmt.Errorf("config mount source '%s' does not refer to any defined config", mount.ConfigName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uint64Ptr is a convenience function to create a pointer to a uint64 value
|
||||||
|
func uint64Ptr(v uint64) *uint64 {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigMount_GetNumericUid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uid string
|
||||||
|
expected *uint64
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty uid returns nil",
|
||||||
|
uid: "",
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid numeric uid",
|
||||||
|
uid: "1000",
|
||||||
|
expected: uint64Ptr(1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero uid",
|
||||||
|
uid: "0",
|
||||||
|
expected: uint64Ptr(0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid non-numeric uid",
|
||||||
|
uid: "root",
|
||||||
|
wantErr: "invalid Uid 'root'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative uid",
|
||||||
|
uid: "-1",
|
||||||
|
wantErr: "invalid Uid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "very large uid",
|
||||||
|
uid: "18446744073709551615", // max uint64
|
||||||
|
wantErr: "invalid Uid '18446744073709551615': value too high",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mount := &ConfigMount{Uid: tt.uid}
|
||||||
|
uid, err := mount.GetNumericUid()
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
assert.Nil(t, uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.expected == nil {
|
||||||
|
assert.Nil(t, uid)
|
||||||
|
} else {
|
||||||
|
require.NotNil(t, uid)
|
||||||
|
assert.Equal(t, *tt.expected, *uid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigMount_GetNumericGid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
gid string
|
||||||
|
expected *uint64
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty gid returns nil",
|
||||||
|
gid: "",
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid numeric gid",
|
||||||
|
gid: "1000",
|
||||||
|
expected: uint64Ptr(1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero gid",
|
||||||
|
gid: "0",
|
||||||
|
expected: uint64Ptr(0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid non-numeric gid",
|
||||||
|
gid: "wheel",
|
||||||
|
wantErr: "invalid Gid 'wheel'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative gid",
|
||||||
|
gid: "-1",
|
||||||
|
wantErr: "invalid Gid",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mount := &ConfigMount{Gid: tt.gid}
|
||||||
|
gid, err := mount.GetNumericGid()
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
assert.Nil(t, gid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.expected == nil {
|
||||||
|
assert.Nil(t, gid)
|
||||||
|
} else {
|
||||||
|
require.NotNil(t, gid)
|
||||||
|
assert.Equal(t, *tt.expected, *gid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfigsAndMounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mode := os.FileMode(0o644)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configs []ConfigSpec
|
||||||
|
mounts []ConfigMount
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty configs and mounts",
|
||||||
|
configs: []ConfigSpec{},
|
||||||
|
mounts: []ConfigMount{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid configs without mounts",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
{Name: "config2", Content: []byte("content2")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid configs with valid mounts",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
{Name: "config2", Content: []byte("content2")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: "/etc/config1"},
|
||||||
|
{ConfigName: "config2", ContainerPath: "/etc/config2", Uid: "1000", Gid: "1000"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config with empty name",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "", Content: []byte("content")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{},
|
||||||
|
wantErr: "config name is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate config names",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
{Name: "config1", Content: []byte("content2")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{},
|
||||||
|
wantErr: "duplicate config name: 'config1'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with empty config name",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "", ContainerPath: "/etc/config"},
|
||||||
|
},
|
||||||
|
wantErr: "config mount source is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount referencing non-existent config",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "nonexistent", ContainerPath: "/etc/config"},
|
||||||
|
},
|
||||||
|
wantErr: "config mount source 'nonexistent' does not refer to any defined config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with invalid uid",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: "/etc/config", Uid: "invalid"},
|
||||||
|
},
|
||||||
|
wantErr: "invalid Uid 'invalid'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with invalid gid",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: "/etc/config", Gid: "invalid"},
|
||||||
|
},
|
||||||
|
wantErr: "invalid Gid 'invalid'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with relative container path",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: "relative/path"},
|
||||||
|
},
|
||||||
|
wantErr: "container path must be absolute",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with empty container path",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: ""},
|
||||||
|
},
|
||||||
|
// Empty path is allowed
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with absolute container path",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: "/absolute/path"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex valid scenario",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "nginx-conf", Content: []byte("server { listen 80; }")},
|
||||||
|
{Name: "app-config", Content: []byte("debug=true")},
|
||||||
|
{Name: "cert", Content: []byte("-----BEGIN CERTIFICATE-----")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "nginx-conf", ContainerPath: "/etc/nginx/nginx.conf", Uid: "0", Gid: "0", Mode: &mode},
|
||||||
|
{ConfigName: "app-config", ContainerPath: "/app/config.env"},
|
||||||
|
{ConfigName: "cert", ContainerPath: "/etc/ssl/cert.pem", Uid: "1000", Gid: "1000"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := ValidateConfigsAndMounts(tt.configs, tt.mounts)
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Container struct {
|
type Container struct {
|
||||||
types.ContainerJSON
|
container.InspectResponse
|
||||||
// created caches the parsed creation time by CreatedTime.
|
// created caches the parsed creation time by CreatedTime.
|
||||||
created time.Time
|
created time.Time
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ func (c *Container) Healthy() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.State.Health.Status == types.Healthy
|
return c.State.Health.Status == container.Healthy
|
||||||
}
|
}
|
||||||
|
|
||||||
// HumanState returns a human-readable description of the container's state. Based on the Docker implementation:
|
// HumanState returns a human-readable description of the container's state. Based on the Docker implementation:
|
||||||
@@ -78,7 +78,7 @@ func (c *Container) HumanState() (string, error) {
|
|||||||
|
|
||||||
if c.State.Health != nil {
|
if c.State.Health != nil {
|
||||||
status := c.State.Health.Status
|
status := c.State.Health.Status
|
||||||
if status == types.Starting {
|
if status == container.Starting {
|
||||||
status = "health: " + status
|
status = "health: " + status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-31
@@ -4,7 +4,6 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -15,9 +14,9 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("exited", func(t *testing.T) {
|
t.Run("exited", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: false,
|
Running: false,
|
||||||
Dead: false,
|
Dead: false,
|
||||||
ExitCode: 0,
|
ExitCode: 0,
|
||||||
@@ -29,9 +28,9 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("running with no health check", func(t *testing.T) {
|
t.Run("running with no health check", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -41,12 +40,12 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("running and healthy", func(t *testing.T) {
|
t.Run("running and healthy", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
Health: &types.Health{
|
Health: &container.Health{
|
||||||
Status: types.Healthy,
|
Status: container.Healthy,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -56,12 +55,12 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("running but unhealthy", func(t *testing.T) {
|
t.Run("running but unhealthy", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
Health: &types.Health{
|
Health: &container.Health{
|
||||||
Status: types.Unhealthy,
|
Status: container.Unhealthy,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -71,11 +70,11 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("running with health starting", func(t *testing.T) {
|
t.Run("running with health starting", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
Health: &types.Health{
|
Health: &container.Health{
|
||||||
Status: "starting",
|
Status: "starting",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -86,9 +85,9 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("dead", func(t *testing.T) {
|
t.Run("dead", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Dead: true,
|
Dead: true,
|
||||||
Running: false,
|
Running: false,
|
||||||
},
|
},
|
||||||
@@ -99,9 +98,9 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("restarting", func(t *testing.T) {
|
t.Run("restarting", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Restarting: true,
|
Restarting: true,
|
||||||
Running: true,
|
Running: true,
|
||||||
ExitCode: 1,
|
ExitCode: 1,
|
||||||
@@ -113,9 +112,9 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("paused", func(t *testing.T) {
|
t.Run("paused", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Paused: true,
|
Paused: true,
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
@@ -280,7 +279,7 @@ func TestContainer_ConflictingServicePorts(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ctr := &ServiceContainer{Container: Container{ContainerJSON: types.ContainerJSON{
|
ctr := &ServiceContainer{Container: Container{InspectResponse: container.InspectResponse{
|
||||||
Config: &container.Config{
|
Config: &container.Config{
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
LabelServicePorts: tt.containerPorts,
|
LabelServicePorts: tt.containerPorts,
|
||||||
|
|||||||
+22
-2
@@ -2,14 +2,34 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types/image"
|
||||||
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MachineImage struct {
|
type MachineImage struct {
|
||||||
Metadata *pb.Metadata
|
Metadata *pb.Metadata
|
||||||
Image types.ImageInspect
|
Image image.InspectResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// MachineImages represents images present on a particular machine.
|
||||||
|
type MachineImages struct {
|
||||||
|
Metadata *pb.Metadata
|
||||||
|
// Images is a list of images present on the machine.
|
||||||
|
Images []image.Summary
|
||||||
|
// ContainerdStore indicates whether Docker on the machine uses the containerd image store
|
||||||
|
// (containerd-snapshotter feature).
|
||||||
|
ContainerdStore bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageFilter defines criteria to filter images in ListImages.
|
||||||
|
type ImageFilter struct {
|
||||||
|
// Machines filters images to those present on the specified machines (names or IDs).
|
||||||
|
// If empty, it matches images on all machines.
|
||||||
|
Machines []string
|
||||||
|
// Name filters images by name (with or without tag). Accepts a wildcard pattern.
|
||||||
|
// If empty, it matches all image names.
|
||||||
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ type ServiceSpec struct {
|
|||||||
Replicas uint `json:",omitempty"`
|
Replicas uint `json:",omitempty"`
|
||||||
// Volumes is list of data volumes that can be mounted into the container.
|
// Volumes is list of data volumes that can be mounted into the container.
|
||||||
Volumes []VolumeSpec
|
Volumes []VolumeSpec
|
||||||
|
// Configs is list of configuration objects that can be mounted into the container.
|
||||||
|
Configs []ConfigSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
// CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined.
|
// CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined.
|
||||||
@@ -79,6 +81,15 @@ func (s *ServiceSpec) Volume(name string) (VolumeSpec, bool) {
|
|||||||
return VolumeSpec{}, false
|
return VolumeSpec{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ServiceSpec) Config(name string) (ConfigSpec, bool) {
|
||||||
|
for _, c := range s.Configs {
|
||||||
|
if c.Name == name {
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ConfigSpec{}, false
|
||||||
|
}
|
||||||
|
|
||||||
// MountedDockerVolumes returns the list of volumes of VolumeTypeVolume type that are mounted into the container.
|
// MountedDockerVolumes returns the list of volumes of VolumeTypeVolume type that are mounted into the container.
|
||||||
func (s *ServiceSpec) MountedDockerVolumes() []VolumeSpec {
|
func (s *ServiceSpec) MountedDockerVolumes() []VolumeSpec {
|
||||||
volumes := make(map[string]VolumeSpec)
|
volumes := make(map[string]VolumeSpec)
|
||||||
@@ -157,6 +168,7 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate volumes
|
||||||
volumeNames := make(map[string]struct{})
|
volumeNames := make(map[string]struct{})
|
||||||
for _, v := range s.Volumes {
|
for _, v := range s.Volumes {
|
||||||
if err := v.Validate(); err != nil {
|
if err := v.Validate(); err != nil {
|
||||||
@@ -177,6 +189,11 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate configs
|
||||||
|
if err := ValidateConfigsAndMounts(s.Configs, s.Container.ConfigMounts); err != nil {
|
||||||
|
return fmt.Errorf("validate service configs and mounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +247,9 @@ type ContainerSpec struct {
|
|||||||
// VolumeMounts specifies how volumes are mounted into the container filesystem.
|
// VolumeMounts specifies how volumes are mounted into the container filesystem.
|
||||||
// Each mount references a volume defined in ServiceSpec.Volumes.
|
// Each mount references a volume defined in ServiceSpec.Volumes.
|
||||||
VolumeMounts []VolumeMount
|
VolumeMounts []VolumeMount
|
||||||
|
// ConfigMounts specifies how configs are mounted into the container filesystem.
|
||||||
|
// Each mount references a config defined in ServiceSpec.Configs.
|
||||||
|
ConfigMounts []ConfigMount
|
||||||
// Volumes is list of data volumes to mount into the container.
|
// Volumes is list of data volumes to mount into the container.
|
||||||
// TODO(lhf): delete all usage, has been replaced with []VolumeMounts.
|
// TODO(lhf): delete all usage, has been replaced with []VolumeMounts.
|
||||||
Volumes []string
|
Volumes []string
|
||||||
@@ -346,6 +366,15 @@ type MachineServiceContainer struct {
|
|||||||
Container ServiceContainer
|
Container ServiceContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Images returns a sorted list of unique images used by the service containers.
|
||||||
|
func (s *Service) Images() []string {
|
||||||
|
images := make(map[string]struct{})
|
||||||
|
for _, ctr := range s.Containers {
|
||||||
|
images[ctr.Container.Config.Image] = struct{}{}
|
||||||
|
}
|
||||||
|
return slices.Sorted(maps.Keys(images))
|
||||||
|
}
|
||||||
|
|
||||||
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
||||||
func (s *Service) Endpoints() []string {
|
func (s *Service) Endpoints() []string {
|
||||||
endpoints := make(map[string]struct{})
|
endpoints := make(map[string]struct{})
|
||||||
|
|||||||
+1
-1
@@ -248,7 +248,7 @@ type VolumeFilter struct {
|
|||||||
Driver string
|
Driver string
|
||||||
// Labels filters volumes by label key-value pairs. Volumes must match all labels.
|
// Labels filters volumes by label key-value pairs. Volumes must match all labels.
|
||||||
Labels map[string]string
|
Labels map[string]string
|
||||||
// MachineIDs filters volumes to those on the specified machines (names or IDs).
|
// Machines filters volumes to those on the specified machines (names or IDs).
|
||||||
Machines []string
|
Machines []string
|
||||||
// Names filters volumes by name. Volumes must match one of the names.
|
// Names filters volumes by name. Volumes must match one of the names.
|
||||||
Names []string
|
Names []string
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
)
|
)
|
||||||
@@ -31,9 +32,12 @@ type Client struct {
|
|||||||
|
|
||||||
var _ api.Client = (*Client)(nil)
|
var _ api.Client = (*Client)(nil)
|
||||||
|
|
||||||
// Connector is an interface for establishing a connection to the machine API.
|
// Connector is an interface for establishing a connection to the cluster.
|
||||||
type Connector interface {
|
type Connector interface {
|
||||||
|
// Connect establishes a gRPC client connection to the machine API.
|
||||||
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
||||||
|
// Dialer returns a proxy dialer for establishing connections within the cluster if supported by the connector.
|
||||||
|
Dialer() (proxy.ContextDialer, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: add support for short syntax configs
|
||||||
|
func configSpecsFromCompose(
|
||||||
|
configs types.Configs, serviceConfigs []types.ServiceConfigObjConfig, workingDir string,
|
||||||
|
) ([]api.ConfigSpec, []api.ConfigMount, error) {
|
||||||
|
var configSpecs []api.ConfigSpec
|
||||||
|
var configMounts []api.ConfigMount
|
||||||
|
|
||||||
|
for _, serviceConfig := range serviceConfigs {
|
||||||
|
var spec api.ConfigSpec
|
||||||
|
|
||||||
|
if projectConfig, exists := configs[serviceConfig.Source]; exists {
|
||||||
|
if projectConfig.External {
|
||||||
|
return nil, nil, fmt.Errorf("external configs are not supported: %s",
|
||||||
|
serviceConfig.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec = api.ConfigSpec{
|
||||||
|
Name: serviceConfig.Source,
|
||||||
|
Content: []byte(projectConfig.Content),
|
||||||
|
}
|
||||||
|
|
||||||
|
// If File is specified, read the file contents
|
||||||
|
if projectConfig.File != "" {
|
||||||
|
configPath := projectConfig.File
|
||||||
|
// TODO: handle this in a separate function?
|
||||||
|
if !filepath.IsAbs(configPath) {
|
||||||
|
configPath = filepath.Join(workingDir, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileContent, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("read config from file '%s': %w", projectConfig.File, err)
|
||||||
|
}
|
||||||
|
spec.Content = fileContent
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, nil, fmt.Errorf("config '%s' not found in project configs", serviceConfig.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
configSpecs = append(configSpecs, spec)
|
||||||
|
|
||||||
|
// Create config mount
|
||||||
|
target := serviceConfig.Target
|
||||||
|
if target == "" {
|
||||||
|
target = "/" + serviceConfig.Source // Default mount path
|
||||||
|
}
|
||||||
|
|
||||||
|
mount := api.ConfigMount{
|
||||||
|
ConfigName: spec.Name,
|
||||||
|
ContainerPath: target,
|
||||||
|
Uid: serviceConfig.UID,
|
||||||
|
Gid: serviceConfig.GID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if serviceConfig.Mode != nil {
|
||||||
|
mode := os.FileMode(*serviceConfig.Mode)
|
||||||
|
mount.Mode = &mode
|
||||||
|
}
|
||||||
|
|
||||||
|
configMounts = append(configMounts, mount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configSpecs, configMounts, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigSpecsFromCompose(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configs types.Configs
|
||||||
|
serviceConfigs []types.ServiceConfigObjConfig
|
||||||
|
expectedSpecs []api.ConfigSpec
|
||||||
|
expectedMounts []api.ConfigMount
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "project-level config with file",
|
||||||
|
configs: types.Configs{
|
||||||
|
"app-config": types.ConfigObjConfig{
|
||||||
|
File: "testdata/config1.txt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serviceConfigs: []types.ServiceConfigObjConfig{
|
||||||
|
{
|
||||||
|
Source: "app-config",
|
||||||
|
Target: "/app/config.json",
|
||||||
|
UID: "1000",
|
||||||
|
GID: "1000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedSpecs: []api.ConfigSpec{
|
||||||
|
{
|
||||||
|
Name: "app-config",
|
||||||
|
Content: []byte("test config content\n"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedMounts: []api.ConfigMount{
|
||||||
|
{
|
||||||
|
ConfigName: "app-config",
|
||||||
|
ContainerPath: "/app/config.json",
|
||||||
|
Uid: "1000",
|
||||||
|
Gid: "1000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config with mode",
|
||||||
|
configs: types.Configs{
|
||||||
|
"nginx-config": types.ConfigObjConfig{
|
||||||
|
File: "./testdata/nginx.conf",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serviceConfigs: []types.ServiceConfigObjConfig{
|
||||||
|
{
|
||||||
|
Source: "nginx-config",
|
||||||
|
Target: "/etc/nginx/nginx.conf",
|
||||||
|
Mode: func() *types.FileMode { m := types.FileMode(0o644); return &m }(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedSpecs: []api.ConfigSpec{
|
||||||
|
{
|
||||||
|
Name: "nginx-config",
|
||||||
|
Content: []byte("user nginx;\nworker_processes auto;\n"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedMounts: []api.ConfigMount{
|
||||||
|
{
|
||||||
|
ConfigName: "nginx-config",
|
||||||
|
ContainerPath: "/etc/nginx/nginx.conf",
|
||||||
|
Mode: func() *os.FileMode { m := os.FileMode(0o644); return &m }(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
configSpecs, configMounts, err := configSpecsFromCompose(tt.configs, tt.serviceConfigs, ".")
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, tt.expectedSpecs, configSpecs)
|
||||||
|
assert.Equal(t, tt.expectedMounts, configMounts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSpecEquals(t *testing.T) {
|
||||||
|
config1 := api.ConfigSpec{
|
||||||
|
Name: "test-config",
|
||||||
|
}
|
||||||
|
|
||||||
|
config2 := api.ConfigSpec{
|
||||||
|
Name: "test-config",
|
||||||
|
}
|
||||||
|
|
||||||
|
config3 := api.ConfigSpec{
|
||||||
|
Name: "test-config",
|
||||||
|
Content: []byte("some content"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, config1.Equals(config2))
|
||||||
|
assert.False(t, config1.Equals(config3))
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
package compose
|
package compose
|
||||||
|
|
||||||
// TODO: make compose, cli, and api packages public.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if machines, ok := service.Extensions[MachinesExtensionKey].(MachinesSource); ok {
|
if machines, ok := service.Extensions[MachinesExtensionKey].(MachinesSource); ok {
|
||||||
spec.Placement.Machines = []string(machines)
|
spec.Placement.Machines = machines
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map LogDriver if specified
|
// Map LogDriver if specified
|
||||||
@@ -105,6 +105,15 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
|||||||
spec.Volumes = volumeSpecs
|
spec.Volumes = volumeSpecs
|
||||||
spec.Container.VolumeMounts = volumeMounts
|
spec.Container.VolumeMounts = volumeMounts
|
||||||
|
|
||||||
|
// Parse configs
|
||||||
|
configSpecs, configMounts, err := configSpecsFromCompose(project.Configs, service.Configs, project.WorkingDir)
|
||||||
|
if err != nil {
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
spec.Configs = configSpecs
|
||||||
|
spec.Container.ConfigMounts = configMounts
|
||||||
|
|
||||||
return spec, nil
|
return spec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
test config content
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
@@ -2,6 +2,7 @@ package connector
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
"github.com/psviderski/uncloud/internal/sshexec"
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
@@ -79,6 +81,15 @@ func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dialer returns a proxy dialer for establishing connections within the cluster through the SSH tunnel.
|
||||||
|
// The connector must be created with an existing SSH client or Connect must be called first.
|
||||||
|
func (c *SSHConnector) Dialer() (proxy.ContextDialer, error) {
|
||||||
|
if c.client == nil {
|
||||||
|
return nil, errors.New("SSH connection must be established first")
|
||||||
|
}
|
||||||
|
return c.client, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SSHConnector) Close() error {
|
func (c *SSHConnector) Close() error {
|
||||||
if c.client != nil {
|
if c.client != nil {
|
||||||
err := c.client.Close()
|
err := c.client.Close()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,10 @@ func (c *TCPConnector) Connect(_ context.Context) (*grpc.ClientConn, error) {
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *TCPConnector) Dialer() (proxy.ContextDialer, error) {
|
||||||
|
return nil, fmt.Errorf("proxy connections are not supported over a TCP connection")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *TCPConnector) Close() error {
|
func (c *TCPConnector) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/network/tunnel"
|
"github.com/psviderski/uncloud/internal/machine/network/tunnel"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
@@ -75,6 +76,10 @@ func (c *WireGuardConnector) Connect(ctx context.Context) (*grpc.ClientConn, err
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WireGuardConnector) Dialer() (proxy.ContextDialer, error) {
|
||||||
|
return nil, fmt.Errorf("proxy connections not implemented for WireGuard connector")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WireGuardConnector) Close() error {
|
func (c *WireGuardConnector) Close() error {
|
||||||
if c.tun != nil {
|
if c.tun != nil {
|
||||||
c.tun.Close()
|
c.tun.Close()
|
||||||
|
|||||||
+6
-37
@@ -4,17 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
dockercommand "github.com/docker/cli/cli/command"
|
"github.com/containerd/errdefs"
|
||||||
dockerconfig "github.com/docker/cli/cli/config"
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/registry"
|
|
||||||
dockerclient "github.com/docker/docker/client"
|
|
||||||
"github.com/docker/docker/pkg/jsonmessage"
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/docker"
|
||||||
|
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -67,7 +64,7 @@ func (cli *Client) CreateContainer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NotFound (No such image) error is expected if the image is missing.
|
// NotFound (No such image) error is expected if the image is missing.
|
||||||
if !dockerclient.IsErrNotFound(err) || !strings.Contains(err.Error(), "No such image") {
|
if !errdefs.IsNotFound(err) || !strings.Contains(err.Error(), "No such image") {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +91,9 @@ func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName
|
|||||||
StatusText: "Pulling",
|
StatusText: "Pulling",
|
||||||
})
|
})
|
||||||
|
|
||||||
opts := docker.PullOptions{}
|
opts := machinedocker.PullOptions{}
|
||||||
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||||
if encodedAuth, err := retrieveRegistryAuthFromDocker(image); err == nil && encodedAuth != "" {
|
if encodedAuth, err := docker.RetrieveLocalDockerRegistryAuth(image); err == nil {
|
||||||
// If RegistryAuth is empty, Uncloud daemon will try to retrieve the credentials from its own Docker config.
|
// If RegistryAuth is empty, Uncloud daemon will try to retrieve the credentials from its own Docker config.
|
||||||
opts.RegistryAuth = encodedAuth
|
opts.RegistryAuth = encodedAuth
|
||||||
}
|
}
|
||||||
@@ -155,34 +152,6 @@ func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrieveRegistryAuthFromDocker 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 retrieveRegistryAuthFromDocker(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// toPullProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
// toPullProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
||||||
// It's based on toPullProgressEvent from Docker Compose.
|
// It's based on toPullProgressEvent from Docker Compose.
|
||||||
func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compare configs.
|
||||||
|
if len(current.Configs) != len(new.Configs) {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
sortConfigs(current.Configs)
|
||||||
|
sortConfigs(new.Configs)
|
||||||
|
for i := range current.Configs {
|
||||||
|
if !current.Configs[i].Equals(new.Configs[i]) {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if any mutable properties changed.
|
// Check if any mutable properties changed.
|
||||||
if !current.Caddy.Equals(new.Caddy) {
|
if !current.Caddy.Equals(new.Caddy) {
|
||||||
return ContainerNeedsRecreate
|
return ContainerNeedsRecreate
|
||||||
@@ -88,3 +100,9 @@ func sortVolumes(volumes []api.VolumeSpec) {
|
|||||||
return volumes[i].Name < volumes[j].Name
|
return volumes[i].Name < volumes[j].Name
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sortConfigs(configs []api.ConfigSpec) {
|
||||||
|
sort.Slice(configs, func(i, j int) bool {
|
||||||
|
return configs[i].Name < configs[j].Name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+471
-1
@@ -2,14 +2,38 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
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/internal/machine/constants"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
|
"github.com/psviderski/uncloud/internal/proxy"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
netproxy "golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (cli *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
func (cli *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||||
images, err := cli.Docker.InspectImage(ctx, id)
|
images, err := cli.Docker.InspectImage(ctx, id)
|
||||||
if dockerclient.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
err = api.ErrNotFound
|
err = api.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,3 +43,449 @@ func (cli *Client) InspectImage(ctx context.Context, id string) ([]api.MachineIm
|
|||||||
func (cli *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
func (cli *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
||||||
return cli.Docker.InspectRemoteImage(ctx, id)
|
return cli.Docker.InspectRemoteImage(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListImages returns a list of images on specified machines in the cluster. If no machines are specified in the filter,
|
||||||
|
// it lists images on all machines.
|
||||||
|
func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]api.MachineImages, error) {
|
||||||
|
// Broadcast the image list request to the specified machines or all machines if none specified.
|
||||||
|
listCtx, machines, err := api.ProxyMachinesContext(ctx, cli, filter.Machines)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request context to broadcast to machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := image.ListOptions{Manifests: true}
|
||||||
|
if filter.Name != "" {
|
||||||
|
opts.Filters = filters.NewArgs(
|
||||||
|
filters.Arg("reference", filter.Name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
optsBytes, err := json.Marshal(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := cli.Docker.GRPCClient.ListImages(listCtx, &pb.ListImagesRequest{Options: optsBytes})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
machineImages := make([]api.MachineImages, len(resp.Messages))
|
||||||
|
for i, msg := range resp.Messages {
|
||||||
|
machineImages[i].Metadata = msg.Metadata
|
||||||
|
// TODO: handle this in the grpc-proxy router and always provide Metadata if possible.
|
||||||
|
if msg.Metadata == nil {
|
||||||
|
// Metadata can be nil if the request was broadcasted to only one machine.
|
||||||
|
machineImages[i].Metadata = &pb.Metadata{
|
||||||
|
Machine: machines[0].Machine.Id,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Replace management IP with machine ID for friendlier error messages.
|
||||||
|
// TODO: migrate Metadata.Machine to use machine ID instead of IP in the grpc-proxy router.
|
||||||
|
if m := machines.FindByManagementIP(msg.Metadata.Machine); m != nil {
|
||||||
|
machineImages[i].Metadata.Machine = m.Machine.Id
|
||||||
|
}
|
||||||
|
if msg.Metadata.Error != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Images) > 0 {
|
||||||
|
if err = json.Unmarshal(msg.Images, &machineImages[i].Images); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal images: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
machineImages[i].ContainerdStore = msg.ContainerdStore
|
||||||
|
}
|
||||||
|
|
||||||
|
return machineImages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushImageOptions struct {
|
||||||
|
// Machines is a list of machine names or IDs to push the image to. If empty and AllMachines is false,
|
||||||
|
// pushes to the machine the client is connected to.
|
||||||
|
Machines []string
|
||||||
|
// AllMachines pushes the image to all machines in the cluster. Takes precedence over Machines field.
|
||||||
|
AllMachines bool
|
||||||
|
// Platform to push for a multi-platform image. Local Docker must use containerd image store
|
||||||
|
// to support multi-platform images.
|
||||||
|
Platform *ocispec.Platform
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushImage pushes a local Docker image to the specified machines. If no machines are specified,
|
||||||
|
// it pushes to the machine the client is connected to.
|
||||||
|
func (cli *Client) PushImage(ctx context.Context, image string, opts PushImageOptions) error {
|
||||||
|
dockerCliWrapped, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv,
|
||||||
|
dockerclient.WithAPIVersionNegotiation())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create Docker client: %w", err)
|
||||||
|
}
|
||||||
|
dockerCli := &docker.Client{Client: dockerCliWrapped}
|
||||||
|
defer dockerCli.Close()
|
||||||
|
|
||||||
|
// Check if Docker image exists locally.
|
||||||
|
if _, err = dockerCli.ImageInspect(ctx, image); err != nil {
|
||||||
|
if errdefs.IsNotFound(err) {
|
||||||
|
return fmt.Errorf("image '%s' not found locally", image)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("inspect image '%s' locally: %w", image, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the machine info for the specified machines or the connected machine if none are specified.
|
||||||
|
var machines []*pb.MachineInfo
|
||||||
|
if opts.AllMachines {
|
||||||
|
machineMembers, err := cli.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
for _, mm := range machineMembers {
|
||||||
|
machines = append(machines, mm.Machine)
|
||||||
|
}
|
||||||
|
} else if len(opts.Machines) > 0 {
|
||||||
|
machineMembers, err := cli.ListMachines(ctx, &api.MachineFilter{
|
||||||
|
NamesOrIDs: opts.Machines,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all specified machines were found.
|
||||||
|
if len(machineMembers) != len(opts.Machines) {
|
||||||
|
var notFound []string
|
||||||
|
for _, nameOrID := range opts.Machines {
|
||||||
|
if machineMembers.FindByNameOrID(nameOrID) == nil {
|
||||||
|
notFound = append(notFound, nameOrID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mm := range machineMembers {
|
||||||
|
machines = append(machines, mm.Machine)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No machines specified, use the connected machine.
|
||||||
|
m, err := cli.MachineClient.Inspect(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect connected machine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the machine has been renamed, the new name will only be stored in the cluster store. .Inspect will return
|
||||||
|
// the old name from the machine config. So we need to fetch the machine info from the cluster.
|
||||||
|
// TODO: make one source of truth for machine info.
|
||||||
|
mm, err := cli.InspectMachine(ctx, m.Id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect machine: %w", err)
|
||||||
|
}
|
||||||
|
machines = append(machines, mm.Machine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push image to all specified machines.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errCh := make(chan error, len(machines))
|
||||||
|
|
||||||
|
// TODO: detect the target machine platform and figure out how to handle scenarios when local and target
|
||||||
|
// platforms differ.
|
||||||
|
for _, m := range machines {
|
||||||
|
wg.Go(func() {
|
||||||
|
if err := cli.pushImageToMachine(ctx, dockerCli, image, m, opts.Platform); err != nil {
|
||||||
|
errCh <- fmt.Errorf("push image to machine '%s': %w", m.Name, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for err = range errCh {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushImageToMachine pushes a local Docker image to a specific machine using local port forwarding to its unregistry.
|
||||||
|
func (cli *Client) pushImageToMachine(
|
||||||
|
ctx context.Context,
|
||||||
|
dockerCli *docker.Client,
|
||||||
|
imageName string,
|
||||||
|
machine *pb.MachineInfo,
|
||||||
|
platform *ocispec.Platform,
|
||||||
|
) error {
|
||||||
|
pw := progress.ContextWriter(ctx)
|
||||||
|
boldStyle := lipgloss.NewStyle().Bold(true)
|
||||||
|
pushEventID := fmt.Sprintf("Pushing %s to %s", boldStyle.Render(imageName), boldStyle.Render(machine.Name))
|
||||||
|
|
||||||
|
// Check the Docker image store type on the target machine.
|
||||||
|
images, err := cli.ListImages(ctx, api.ImageFilter{
|
||||||
|
Machines: []string{machine.Id},
|
||||||
|
Name: "%invalid-name-to-only-check-store-type%",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check Docker image store type on machine '%s': %w", machine.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only support Docker with containerd image store enabled to avoid the confusion of pushing images to containerd
|
||||||
|
// and then not being able to see and use them in Docker.
|
||||||
|
if !images[0].ContainerdStore {
|
||||||
|
pw.Event(progress.NewEvent(pushEventID, progress.Error, "containerd image store required"))
|
||||||
|
return fmt.Errorf("docker on machine '%s' is not using containerd image store, "+
|
||||||
|
"which is required for pushing images. Follow the instructions to enable it: "+
|
||||||
|
"https://docs.docker.com/engine/storage/containerd/", machine.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
machineSubnet, _ := machine.Network.Subnet.ToPrefix()
|
||||||
|
machineIP := network.MachineIP(machineSubnet)
|
||||||
|
unregistryAddr := net.JoinHostPort(machineIP.String(), strconv.Itoa(constants.UnregistryPort))
|
||||||
|
|
||||||
|
dialer, err := cli.connector.Dialer()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get proxy dialer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyEventID := fmt.Sprintf("Proxy to unregistry on %s", boldStyle.Render(machine.Name))
|
||||||
|
pw.Event(progress.StartingEvent(proxyEventID))
|
||||||
|
|
||||||
|
// Forward local port 127.0.0.1:PORT to the machine's unregistry over the established client connection.
|
||||||
|
unregProxy, err := newUnregistryProxy(ctx, unregistryAddr, dialer, func(err error) {
|
||||||
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
|
return fmt.Errorf("create local proxy to unregistry on machine '%s': %w", machine.Name, err)
|
||||||
|
}
|
||||||
|
// Get the local port the unregistry proxy is listening on.
|
||||||
|
proxyPort := unregProxy.Listener.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
proxyCtx, cancelProxy := context.WithCancel(ctx)
|
||||||
|
proxyCtrID := ""
|
||||||
|
pushImageTag := ""
|
||||||
|
|
||||||
|
// Cleanup function to remove temporary resources and stop proxies.
|
||||||
|
cleanup := func() {
|
||||||
|
// Remove temporary image tag.
|
||||||
|
if pushImageTag != "" {
|
||||||
|
dockerCli.ImageRemove(ctx, pushImageTag, image.RemoveOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove socat proxy container.
|
||||||
|
if proxyCtrID != "" {
|
||||||
|
dockerCli.ContainerRemove(ctx, proxyCtrID, container.RemoveOptions{Force: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelProxy()
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
go unregProxy.Run(proxyCtx)
|
||||||
|
|
||||||
|
dockerVirtualised, err := isDockerVirtualised(ctx, dockerCli)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if dockerVirtualised {
|
||||||
|
// Run socat proxy container to forward a localhost port from within the Docker VM to the host machine.
|
||||||
|
pw.Event(progress.Event{
|
||||||
|
ID: proxyEventID,
|
||||||
|
Status: progress.Working,
|
||||||
|
StatusText: "Starting",
|
||||||
|
Text: "(detected Docker in VM locally, starting socat proxy container)",
|
||||||
|
})
|
||||||
|
|
||||||
|
proxyCtrID, proxyPort, err = runDockerVMProxyContainer(ctx, dockerCli, proxyPort)
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
|
return fmt.Errorf("run socat container to proxy unregistry to Docker VM: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pw.Event(progress.Event{
|
||||||
|
ID: proxyEventID,
|
||||||
|
Status: progress.Done,
|
||||||
|
StatusText: "Started",
|
||||||
|
Text: fmt.Sprintf("(localhost:%d → %s)", proxyPort, unregistryAddr),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tag the image for pushing through the proxy.
|
||||||
|
pushImageTag = fmt.Sprintf("127.0.0.1:%d/%s", proxyPort, imageName)
|
||||||
|
if err = dockerCli.ImageTag(ctx, imageName, pushImageTag); err != nil {
|
||||||
|
return fmt.Errorf("tag image for push: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the image through the proxy.
|
||||||
|
pw.Event(progress.NewEvent(pushEventID, progress.Working, "Pushing"))
|
||||||
|
|
||||||
|
pushCh, err := dockerCli.PushImage(ctx, pushImageTag, image.PushOptions{
|
||||||
|
Platform: platform,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(pushEventID, progress.Error, err.Error()))
|
||||||
|
return fmt.Errorf("push image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for push to complete by reading all progress messages and converting them to events.
|
||||||
|
// If the context is cancelled, the pushCh will receive a context cancellation error.
|
||||||
|
for msg := range pushCh {
|
||||||
|
if msg.Err != nil {
|
||||||
|
pw.Event(progress.NewEvent(pushEventID, progress.Error, msg.Err.Error()))
|
||||||
|
return fmt.Errorf("push image: %w", msg.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: support quite mode like in compose: --quiet Push without printing progress information
|
||||||
|
if e := toPushProgressEvent(msg.Message); e != nil {
|
||||||
|
e.ID = fmt.Sprintf("Layer %s on %s:", e.ID, boldStyle.Render(machine.Name))
|
||||||
|
e.ParentID = pushEventID
|
||||||
|
pw.Event(*e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.Event(progress.NewEvent(pushEventID, progress.Done, "Pushed"))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUnregistryProxy(
|
||||||
|
ctx context.Context, remoteAddr string, dialer netproxy.ContextDialer, onError func(error),
|
||||||
|
) (*proxy.Proxy, error) {
|
||||||
|
// Test remote connectivity before creating a proxy.
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
testConn, err := dialer.DialContext(ctx, "tcp", remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to remote address '%s': %w", remoteAddr, err)
|
||||||
|
}
|
||||||
|
testConn.Close()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listen on an available port on 127.0.0.1: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &proxy.Proxy{
|
||||||
|
Listener: listener,
|
||||||
|
RemoteAddr: remoteAddr,
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
OnError: onError,
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDockerVirtualised checks if Docker is running in a virtualised environment like Docker Desktop on macOS.
|
||||||
|
func isDockerVirtualised(ctx context.Context, dockerCli *docker.Client) (bool, error) {
|
||||||
|
info, err := dockerCli.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("get Docker info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualisedHostnames := []string{"docker-desktop", "colima"}
|
||||||
|
for _, name := range virtualisedHostnames {
|
||||||
|
if strings.Contains(strings.ToLower(info.Name), name) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDockerVMProxyContainer creates a socat container to proxy an available localhost port within the Docker VM
|
||||||
|
// (e.g. Docker Desktop on macOS) to the specified target port on the host machine.
|
||||||
|
// Returns the container ID and the localhost port the container port is bound to.
|
||||||
|
// TODO: accept custom image name.
|
||||||
|
func runDockerVMProxyContainer(ctx context.Context, dockerCli *docker.Client, targetPort int) (string, int, error) {
|
||||||
|
suffix, err := secret.RandomAlphaNumeric(4)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("generate random suffix: %w", err)
|
||||||
|
}
|
||||||
|
containerName := fmt.Sprintf("uncloud-push-proxy-%s", suffix)
|
||||||
|
|
||||||
|
containerPort := nat.Port("5000/tcp")
|
||||||
|
config := &container.Config{
|
||||||
|
// TODO: make image configurable.
|
||||||
|
Image: "alpine/socat:latest",
|
||||||
|
// Reset the default entrypoint "socat".
|
||||||
|
Entrypoint: []string{},
|
||||||
|
Cmd: []string{
|
||||||
|
"timeout", "1800", // Auto-terminate socat after 30 minutes.
|
||||||
|
"socat",
|
||||||
|
"TCP-LISTEN:5000,fork,reuseaddr",
|
||||||
|
fmt.Sprintf("TCP-CONNECT:host.docker.internal:%d", targetPort),
|
||||||
|
},
|
||||||
|
ExposedPorts: nat.PortSet{
|
||||||
|
containerPort: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get an available port on localhost to bind the container port to by creating a temporary listener and closing it.
|
||||||
|
// We need to explicitly specify the host port and not rely on Docker mapping because if not specified,
|
||||||
|
// 'docker push' from Docker Desktop is unable to reach the randomly mapped one for some reason.
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("reserve a local port: %w", err)
|
||||||
|
}
|
||||||
|
hostPort := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
listener.Close()
|
||||||
|
|
||||||
|
hostConfig := &container.HostConfig{
|
||||||
|
AutoRemove: true,
|
||||||
|
PortBindings: nat.PortMap{
|
||||||
|
containerPort: []nat.PortBinding{
|
||||||
|
{
|
||||||
|
HostIP: "127.0.0.1",
|
||||||
|
HostPort: strconv.Itoa(hostPort),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := dockerCli.CreateContainerWithImagePull(ctx, containerName, config, hostConfig)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("create socat proxy container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
dockerCli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = dockerCli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||||
|
// Clean up if start fails.
|
||||||
|
cleanup()
|
||||||
|
return "", 0, fmt.Errorf("start socat proxy container %s: %w", resp.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.ID, hostPort, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toPushProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
||||||
|
// It's based on toPushProgressEvent from Docker Compose.
|
||||||
|
func toPushProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
||||||
|
if jm.ID == "" || jm.Progress == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
status := progress.Working
|
||||||
|
percent := 0
|
||||||
|
|
||||||
|
if jm.Progress.Total > 0 {
|
||||||
|
percent = int(jm.Progress.Current * 100 / jm.Progress.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch jm.Status {
|
||||||
|
case "Pushed", "Layer already exists":
|
||||||
|
status = progress.Done
|
||||||
|
percent = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return &progress.Event{
|
||||||
|
ID: jm.ID,
|
||||||
|
Current: jm.Progress.Current,
|
||||||
|
Total: jm.Progress.Total,
|
||||||
|
Percent: percent,
|
||||||
|
Text: jm.Status,
|
||||||
|
Status: status,
|
||||||
|
StatusText: jm.Progress.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Ser
|
|||||||
|
|
||||||
machineID := ""
|
machineID := ""
|
||||||
if mc.Metadata == nil {
|
if mc.Metadata == nil {
|
||||||
// ListContainers was proxied to only one machine.
|
// ListServiceContainers was proxied to only one machine.
|
||||||
for _, v := range machineIDByManagementIP {
|
for _, v := range machineIDByManagementIP {
|
||||||
machineID = v
|
machineID = v
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
dockerclient "github.com/docker/docker/client"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
@@ -123,7 +123,7 @@ func (cli *Client) RemoveVolume(ctx context.Context, machineNameOrID, volumeName
|
|||||||
pw.Event(progress.RemovingEvent(eventID))
|
pw.Event(progress.RemovingEvent(eventID))
|
||||||
|
|
||||||
if err = cli.Docker.RemoveVolume(ctx, volumeName, force); err != nil {
|
if err = cli.Docker.RemoveVolume(ctx, volumeName, force); err != nil {
|
||||||
if dockerclient.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return api.ErrNotFound
|
return api.ErrNotFound
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
+69
-1
@@ -17,10 +17,25 @@ UNCLOUD_DATA_DIR=${UNCLOUD_DATA_DIR:-/var/lib/uncloud}
|
|||||||
CORROSION_GITHUB_URL="https://github.com/psviderski/corrosion"
|
CORROSION_GITHUB_URL="https://github.com/psviderski/corrosion"
|
||||||
CORROSION_VERSION=${CORROSION_VERSION:-latest}
|
CORROSION_VERSION=${CORROSION_VERSION:-latest}
|
||||||
|
|
||||||
|
DOCKER_ALREADY_INSTALLED=false
|
||||||
|
CONTAINERD_IMAGE_STORE_ENABLED=false
|
||||||
|
DOCKER_DAEMON_CONFIG_FILE=${DOCKER_DAEMON_CONFIG_FILE:-/etc/docker/daemon.json}
|
||||||
|
# Docker daemon configuration optimised for Uncloud.
|
||||||
|
DOCKER_DAEMON_CONFIG='{
|
||||||
|
"features": {
|
||||||
|
"containerd-snapshotter": true
|
||||||
|
},
|
||||||
|
"live-restore": true
|
||||||
|
}'
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
echo -e "\033[1;32m$1\033[0m"
|
echo -e "\033[1;32m$1\033[0m"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "\033[1;33m$1\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
error() {
|
error() {
|
||||||
echo -e "\033[1;31mERROR: $1\033[0m" >&2
|
echo -e "\033[1;31mERROR: $1\033[0m" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -52,12 +67,35 @@ install_docker() {
|
|||||||
if command_exists dockerd; then
|
if command_exists dockerd; then
|
||||||
log "✓ Docker is already installed."
|
log "✓ Docker is already installed."
|
||||||
docker version
|
docker version
|
||||||
|
|
||||||
|
DOCKER_ALREADY_INSTALLED=true
|
||||||
|
# Check if the installed Docker configured to use the containerd image store.
|
||||||
|
local driver_status
|
||||||
|
driver_status=$(docker info -f '{{ .DriverStatus }}' 2>/dev/null)
|
||||||
|
if [[ "$driver_status" == *"io.containerd.snapshotter"* ]]; then
|
||||||
|
CONTAINERD_IMAGE_STORE_ENABLED="true"
|
||||||
|
fi
|
||||||
|
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "⏳ Installing Docker..."
|
log "⏳ Installing Docker..."
|
||||||
curl -fsSL https://get.docker.com | sh
|
curl -fsSL https://get.docker.com | sh
|
||||||
log "✓ Docker installed successfully."
|
|
||||||
|
# Configure Docker daemon for new installation.
|
||||||
|
# Create Docker daemon config directory if it doesn't exist.
|
||||||
|
local docker_config_dir
|
||||||
|
docker_config_dir=$(dirname "${DOCKER_DAEMON_CONFIG_FILE}")
|
||||||
|
if [ ! -d "${docker_config_dir}" ]; then
|
||||||
|
mkdir -p "${docker_config_dir}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "⏳ Configuring Docker daemon (${DOCKER_DAEMON_CONFIG_FILE}) to optimise it for Uncloud..."
|
||||||
|
echo "${DOCKER_DAEMON_CONFIG}" > "${DOCKER_DAEMON_CONFIG_FILE}"
|
||||||
|
|
||||||
|
systemctl restart docker
|
||||||
|
|
||||||
|
log "✓ Docker installed and configured successfully."
|
||||||
}
|
}
|
||||||
|
|
||||||
create_uncloud_user_and_group() {
|
create_uncloud_user_and_group() {
|
||||||
@@ -271,4 +309,34 @@ install_corrosion
|
|||||||
install_corrosion_systemd
|
install_corrosion_systemd
|
||||||
start_uncloud
|
start_uncloud
|
||||||
|
|
||||||
|
# Show warning if Docker was already installed without containerd image store enabled.
|
||||||
|
if [ "$DOCKER_ALREADY_INSTALLED" = "true" ] && [ "$CONTAINERD_IMAGE_STORE_ENABLED" = "false" ]; then
|
||||||
|
echo ""
|
||||||
|
warning "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
warning "⚠️ IMPORTANT: Containerd image store configuration"
|
||||||
|
warning "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
warning "Docker was already installed on the machine but it doesn't use the containerd"
|
||||||
|
warning "image store. Uncloud works best with the containerd image store enabled in Docker."
|
||||||
|
warning "It allows Docker to directly use the images stores in containerd (pushed with"
|
||||||
|
warning "'uc image push') without duplicating them in Docker. This saves disk space and"
|
||||||
|
warning "makes image management more efficient."
|
||||||
|
echo ""
|
||||||
|
warning "See https://docs.docker.com/engine/storage/containerd/ for more details."
|
||||||
|
echo ""
|
||||||
|
warning "To enable it, run the following commands on the machine:"
|
||||||
|
echo ""
|
||||||
|
echo "sudo bash -c 'cat > ${DOCKER_DAEMON_CONFIG_FILE} << EOF"
|
||||||
|
echo "${DOCKER_DAEMON_CONFIG}"
|
||||||
|
echo "EOF'"
|
||||||
|
echo "sudo systemctl restart docker"
|
||||||
|
echo ""
|
||||||
|
warning "WARNING: Switching to containerd image store causes you to temporarily lose images"
|
||||||
|
warning "and containers created using the classic storage driver. Those resources still"
|
||||||
|
warning "exist on your filesystem, and you can retrieve them by turning off the containerd"
|
||||||
|
warning "image store feature."
|
||||||
|
warning "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
log "✓ Uncloud installed on the machine successfully! 🎉"
|
log "✓ Uncloud installed on the machine successfully! 🎉"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestComposeBuild(t *testing.T) {
|
|||||||
clusterName := "ucind-test.compose-build"
|
clusterName := "ucind-test.compose-build"
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
registryInternalPort := "5000/tcp"
|
registryInternalPort := "5001/tcp"
|
||||||
clusterOpts := ucind.CreateClusterOptions{
|
clusterOpts := ucind.CreateClusterOptions{
|
||||||
Machines: 1,
|
Machines: 1,
|
||||||
PortMap: nat.PortMap{
|
PortMap: nat.PortMap{
|
||||||
@@ -79,7 +79,7 @@ func TestComposeBuild(t *testing.T) {
|
|||||||
Ports: []api.PortSpec{
|
Ports: []api.PortSpec{
|
||||||
{
|
{
|
||||||
ContainerPort: 5000,
|
ContainerPort: 5000,
|
||||||
PublishedPort: 5000,
|
PublishedPort: 5001,
|
||||||
Mode: api.PortModeHost,
|
Mode: api.PortModeHost,
|
||||||
Protocol: api.ProtocolTCP,
|
Protocol: api.ProtocolTCP,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComposeConfigs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
clusterName := "ucind-test.compose-configs"
|
||||||
|
ctx := context.Background()
|
||||||
|
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 1}, true)
|
||||||
|
|
||||||
|
machine := c.Machines[0]
|
||||||
|
cli, err := machine.Connect(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("basic configs", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
name := "web"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
removeServices(t, cli, name)
|
||||||
|
})
|
||||||
|
|
||||||
|
project, err := compose.LoadProject(ctx, []string{"fixtures/compose-configs.yaml"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
deploy, err := compose.NewDeployment(ctx, cli, project)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
plan, err := deploy.Plan(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, plan.Operations, 1, "Expected 1 service deployment")
|
||||||
|
|
||||||
|
err = deploy.Run(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc, err := cli.InspectService(ctx, name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectedSpec := api.ServiceSpec{
|
||||||
|
Name: name,
|
||||||
|
Mode: api.ServiceModeReplicated,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Command: []string{"true"},
|
||||||
|
Image: "busybox:1.37.0-uclibc",
|
||||||
|
ConfigMounts: []api.ConfigMount{
|
||||||
|
{
|
||||||
|
ConfigName: "from-file",
|
||||||
|
ContainerPath: "/etc/config-from-file.conf",
|
||||||
|
Mode: func() *os.FileMode { m := os.FileMode(0o644); return &m }(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ConfigName: "from-inline",
|
||||||
|
ContainerPath: "/etc/config-inline.conf",
|
||||||
|
Uid: "1000",
|
||||||
|
Gid: "1000",
|
||||||
|
Mode: func() *os.FileMode { m := os.FileMode(0o600); return &m }(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Configs: []api.ConfigSpec{
|
||||||
|
{
|
||||||
|
Name: "from-file",
|
||||||
|
Content: []byte("this is file config\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "from-inline",
|
||||||
|
Content: []byte("this is inline config\n"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Replicas: 1,
|
||||||
|
}
|
||||||
|
assertServiceMatchesSpec(t, svc, expectedSpec)
|
||||||
|
|
||||||
|
// Verify deployment is up-to-date after initial deployment
|
||||||
|
deploy, err = compose.NewDeployment(ctx, cli, project)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
plan, err = deploy.Plan(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, plan.Operations, 0, "Expected no new operations after configs deployment")
|
||||||
|
|
||||||
|
// Verify the config files are actually created in the container and contain expected content
|
||||||
|
containerName := svc.Containers[0].Container.Name
|
||||||
|
|
||||||
|
configContentFirst, err := readFileInfoInContainer(t, &machine, containerName, "/etc/config-from-file.conf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, fileInfo{
|
||||||
|
permissions: 0o644,
|
||||||
|
content: "this is file config\n",
|
||||||
|
}, configContentFirst)
|
||||||
|
|
||||||
|
configContentSecond, err := readFileInfoInContainer(t, &machine, containerName, "/etc/config-inline.conf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, fileInfo{
|
||||||
|
permissions: 0o600,
|
||||||
|
content: "this is inline config\n",
|
||||||
|
}, configContentSecond)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestInternalDNS tests the internal DNS functionality including the new machine-specific service lookups
|
||||||
|
func TestInternalDNS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
clusterName := "ucind-test.dns"
|
||||||
|
ctx := context.Background()
|
||||||
|
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 3}, true)
|
||||||
|
|
||||||
|
cli, err := c.Machines[0].Connect(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test service with multiple replicas across machines
|
||||||
|
serviceName := "test-dns-service"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := cli.RemoveService(ctx, serviceName)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "not found") {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deploy a service across all machines in global mode using pause container
|
||||||
|
spec := api.ServiceSpec{
|
||||||
|
Name: serviceName,
|
||||||
|
Mode: api.ServiceModeGlobal,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "portainer/pause:latest",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment := cli.NewDeployment(spec, nil)
|
||||||
|
_, err = deployment.Run(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the service to be deployed
|
||||||
|
var svc api.Service
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
svc, err = cli.InspectService(ctx, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Should have 3 containers (one per machine) and all should be running
|
||||||
|
if len(svc.Containers) != 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
if ctr.Container.State.Status != "running" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, 30*time.Second, 1*time.Second, "Service should be deployed and running on all machines")
|
||||||
|
|
||||||
|
// Get Docker client to access ucind machine containers (shared across all DNS tests)
|
||||||
|
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Helper function to create DNS query service spec
|
||||||
|
createDNSQuerySpec := func(name, dnsQuery, outputFile string) api.ServiceSpec {
|
||||||
|
return api.ServiceSpec{
|
||||||
|
Name: name,
|
||||||
|
Mode: api.ServiceModeReplicated,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "wbitt/network-multitool",
|
||||||
|
Command: []string{"sh", "-c", fmt.Sprintf("nslookup %s > %s 2>&1 && echo 'DNS query completed' && sleep infinity",
|
||||||
|
dnsQuery, outputFile)},
|
||||||
|
VolumeMounts: []api.VolumeMount{
|
||||||
|
{
|
||||||
|
VolumeName: "host-tmp",
|
||||||
|
ContainerPath: "/tmp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Volumes: []api.VolumeSpec{
|
||||||
|
{
|
||||||
|
Name: "host-tmp",
|
||||||
|
Type: "bind",
|
||||||
|
BindOptions: &api.BindOptions{
|
||||||
|
HostPath: "/tmp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Replicas: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to run DNS query and get results
|
||||||
|
runDNSQuery := func(t *testing.T, serviceName, dnsQuery, outputFile string) string {
|
||||||
|
spec := createDNSQuerySpec(serviceName, dnsQuery, outputFile)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cli.RemoveService(ctx, spec.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Run the DNS query service
|
||||||
|
resp, err := cli.RunService(ctx, spec)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the DNS query to complete
|
||||||
|
var querySvc api.Service
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
querySvc, err = cli.InspectService(ctx, resp.ID)
|
||||||
|
if err != nil || len(querySvc.Containers) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return querySvc.Containers[0].Container.State.Status == "running"
|
||||||
|
}, 30*time.Second, 1*time.Second, "DNS query container should be running")
|
||||||
|
|
||||||
|
// Give some time for DNS query to complete and write to file
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
// Find which ucind machine the DNS query container is running on
|
||||||
|
ctr := querySvc.Containers[0]
|
||||||
|
machineID := ctr.MachineID
|
||||||
|
|
||||||
|
// Get the ucind machine container name for this machine
|
||||||
|
var ucindMachineName string
|
||||||
|
for _, machine := range c.Machines {
|
||||||
|
if machine.ID == machineID {
|
||||||
|
ucindMachineName = machine.ContainerName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, ucindMachineName, "Should find ucind machine container name")
|
||||||
|
|
||||||
|
// Read the DNS results from the ucind machine's filesystem
|
||||||
|
exec, err := dockerCli.ContainerExecCreate(ctx, ucindMachineName, container.ExecOptions{
|
||||||
|
Cmd: []string{"cat", outputFile},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp2, err := dockerCli.ContainerExecAttach(ctx, exec.ID, container.ExecAttachOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp2.Close()
|
||||||
|
|
||||||
|
// Read the DNS output
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
n, _ := resp2.Reader.Read(buf)
|
||||||
|
return string(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to verify DNS output doesn't contain errors
|
||||||
|
assertNoDNSErrors := func(t *testing.T, dnsOutput string) {
|
||||||
|
assert.NotContains(t, dnsOutput, "can't resolve", "DNS query should not contain resolution errors")
|
||||||
|
assert.NotContains(t, dnsOutput, "Name or service not known",
|
||||||
|
"DNS query should not contain unknown service errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("service name resolves to all container IPs", func(t *testing.T) {
|
||||||
|
dnsOutput := runDNSQuery(t, "dns-test-service-name", serviceName+".internal", "/tmp/dns_result.txt")
|
||||||
|
t.Logf("DNS query output:\n%s", dnsOutput)
|
||||||
|
|
||||||
|
// Verify that all service container IPs are in the DNS response
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
containerIP := ctr.Container.UncloudNetworkIP().String()
|
||||||
|
assert.Contains(t, dnsOutput, containerIP,
|
||||||
|
"Service DNS should resolve to container IP %s", containerIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDNSErrors(t, dnsOutput)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("machine-specific service DNS lookups", func(t *testing.T) {
|
||||||
|
// Test the new <machine-id>.m.<service-name>.internal DNS feature
|
||||||
|
for i, targetContainer := range svc.Containers {
|
||||||
|
targetMachineID := targetContainer.MachineID
|
||||||
|
targetContainerIP := targetContainer.Container.UncloudNetworkIP().String()
|
||||||
|
|
||||||
|
// Construct the machine-specific DNS name
|
||||||
|
machineSpecificDNS := targetMachineID + ".m." + serviceName + ".internal"
|
||||||
|
outputFile := fmt.Sprintf("/tmp/dns_result_machine_%d.txt", i)
|
||||||
|
|
||||||
|
dnsOutput := runDNSQuery(t, fmt.Sprintf("dns-test-machine-%d", i), machineSpecificDNS, outputFile)
|
||||||
|
t.Logf("Machine-specific DNS query output for %s:\n%s", machineSpecificDNS, dnsOutput)
|
||||||
|
|
||||||
|
// Verify that the specific container IP is returned
|
||||||
|
assert.Contains(t, dnsOutput, targetContainerIP,
|
||||||
|
"Machine-specific DNS %s should resolve to container IP %s",
|
||||||
|
machineSpecificDNS, targetContainerIP)
|
||||||
|
|
||||||
|
// Verify that other container IPs are not returned (machine-specific should return only one IP)
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
if ctr.MachineID != targetMachineID {
|
||||||
|
otherContainerIP := ctr.Container.UncloudNetworkIP().String()
|
||||||
|
assert.NotContains(t, dnsOutput, otherContainerIP,
|
||||||
|
"Machine-specific DNS %s should not resolve to other container IP %s",
|
||||||
|
machineSpecificDNS, otherContainerIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDNSErrors(t, dnsOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("service ID DNS lookup", func(t *testing.T) {
|
||||||
|
// Test that service ID also resolves (existing functionality)
|
||||||
|
dnsOutput := runDNSQuery(t, "dns-test-service-id", svc.ID+".internal", "/tmp/dns_result_service_id.txt")
|
||||||
|
t.Logf("Service ID DNS query output:\n%s", dnsOutput)
|
||||||
|
|
||||||
|
// Verify that all service container IPs are in the DNS response
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
containerIP := ctr.Container.UncloudNetworkIP().String()
|
||||||
|
assert.Contains(t, dnsOutput, containerIP,
|
||||||
|
"Service ID DNS should resolve to container IP %s", containerIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDNSErrors(t, dnsOutput)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: busybox:1.37.0-uclibc
|
||||||
|
command: ["true"]
|
||||||
|
configs:
|
||||||
|
- source: from-file
|
||||||
|
target: /etc/config-from-file.conf
|
||||||
|
mode: 0644
|
||||||
|
- source: from-inline
|
||||||
|
target: /etc/config-inline.conf
|
||||||
|
uid: "1000"
|
||||||
|
gid: "1000"
|
||||||
|
mode: 0600
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
configs:
|
||||||
|
from-file:
|
||||||
|
file: ./configs/test-config.conf
|
||||||
|
from-inline:
|
||||||
|
content: |
|
||||||
|
this is inline config
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
this is file config
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fileInfo struct {
|
||||||
|
permissions os.FileMode
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// a helper function that takes a machine, container name inside it and file path, and returns the file contents along with metadata
|
||||||
|
//
|
||||||
|
// Uncloud API does not currently expose exec functionality to run commands inside containers.
|
||||||
|
// Instead this helper function uses "docker cp" inside the ucind container to copy the file from the target container
|
||||||
|
// to a temporary location (also inside the ucind container), and then inspect its content and permissions.
|
||||||
|
func readFileInfoInContainer(t *testing.T, machine *ucind.Machine, containerName, filePath string) (fileInfo, error) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||||
|
if err != nil {
|
||||||
|
return fileInfo{}, err
|
||||||
|
}
|
||||||
|
defer dockerCli.Close()
|
||||||
|
|
||||||
|
machineContainerName := fmt.Sprintf("%s-%s", machine.ClusterName, machine.Name)
|
||||||
|
|
||||||
|
// 1. Create an exec instance
|
||||||
|
fileLocation := fmt.Sprintf("%s:%s", containerName, filePath)
|
||||||
|
tmpFileLocation := fmt.Sprintf("/tmp/%s-%s", containerName, filepath.Base(filePath))
|
||||||
|
|
||||||
|
cmdDocker := fmt.Sprintf("docker cp %s %s", fileLocation, tmpFileLocation)
|
||||||
|
cmdPermissions := fmt.Sprintf("stat -c %%a %s", tmpFileLocation)
|
||||||
|
cmdCat := fmt.Sprintf("cat %s", tmpFileLocation)
|
||||||
|
cmdCombined := fmt.Sprintf("%s; %s; %s", cmdDocker, cmdPermissions, cmdCat)
|
||||||
|
|
||||||
|
execConfig := container.ExecOptions{
|
||||||
|
Cmd: []string{"sh", "-ec", cmdCombined},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
}
|
||||||
|
resp, err := dockerCli.ContainerExecCreate(ctx, machineContainerName, execConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Attach to the exec session
|
||||||
|
hijackResp, err := dockerCli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fileInfo{}, err
|
||||||
|
}
|
||||||
|
defer hijackResp.Close()
|
||||||
|
|
||||||
|
// 3. Inspect result
|
||||||
|
inspectResp, err := dockerCli.ContainerExecInspect(ctx, resp.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 0, inspectResp.ExitCode, "Expected exit code 0 from exec command")
|
||||||
|
|
||||||
|
// 4. Read output to string using stdcopy to demultiplex the Docker stream
|
||||||
|
var stdout, stderr strings.Builder
|
||||||
|
_, err = stdcopy.StdCopy(&stdout, &stderr, hijackResp.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return fileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
return fileInfo{}, fmt.Errorf("stderr output: %s", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Parse output: permissions, content
|
||||||
|
outputLines := strings.SplitN(stdout.String(), "\n", 2)
|
||||||
|
if len(outputLines) < 2 {
|
||||||
|
return fileInfo{}, fmt.Errorf("unexpected output format, expected at least 2 lines, got %d", len(outputLines))
|
||||||
|
}
|
||||||
|
permissions := outputLines[0]
|
||||||
|
// convert to octal number
|
||||||
|
permissionsOctal, err := strconv.ParseInt(permissions, 8, 32)
|
||||||
|
if err != nil {
|
||||||
|
return fileInfo{}, fmt.Errorf("parse file permissions: %w", err)
|
||||||
|
}
|
||||||
|
mode := os.FileMode(permissionsOctal)
|
||||||
|
|
||||||
|
fileContent := outputLines[1]
|
||||||
|
|
||||||
|
return fileInfo{
|
||||||
|
permissions: mode,
|
||||||
|
content: fileContent,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -1189,6 +1190,75 @@ myapp.example.com {
|
|||||||
"Containers should be on machines where the volume exists")
|
"Containers should be on machines where the volume exists")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("list images for replicated service", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
serviceName := "test-list-images-replicated"
|
||||||
|
uniqueImage := "portainer/pause:3.9" // Unique image not used by other tests.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := cli.RemoveService(ctx, serviceName)
|
||||||
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
spec := api.ServiceSpec{
|
||||||
|
Name: serviceName,
|
||||||
|
Mode: api.ServiceModeReplicated,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: uniqueImage,
|
||||||
|
},
|
||||||
|
Replicas: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment := cli.NewDeployment(spec, nil)
|
||||||
|
_, err = deployment.Run(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc, err := cli.InspectService(ctx, serviceName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertServiceMatchesSpec(t, svc, spec)
|
||||||
|
|
||||||
|
// Get the machine IDs where containers are running.
|
||||||
|
machinesWithContainers := serviceMachines(svc)
|
||||||
|
assert.Len(t, machinesWithContainers.ToSlice(), 2, "Containers should be on 2 different machines")
|
||||||
|
|
||||||
|
var machineWithoutContainer string
|
||||||
|
for _, m := range c.Machines {
|
||||||
|
if !machinesWithContainers.Contains(m.ID) {
|
||||||
|
machineWithoutContainer = m.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.NotEmpty(t, machineWithoutContainer, "Should have found a machine without container")
|
||||||
|
|
||||||
|
machineImages, err := cli.ListImages(ctx, api.ImageFilter{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, machineImages, 3, "Should get images from all 3 machines")
|
||||||
|
|
||||||
|
for _, mi := range machineImages {
|
||||||
|
// Checking only DockerImages because the machines in ucind cluster don't use the containerd image store.
|
||||||
|
if !machinesWithContainers.Contains(mi.Metadata.Machine) {
|
||||||
|
// This is the machine without service containers, it should not have the unique image.
|
||||||
|
for _, img := range mi.Images {
|
||||||
|
assert.NotContains(t, img.RepoTags, uniqueImage)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the unique image is present on the machine where a service container is running.
|
||||||
|
hasImage := false
|
||||||
|
for _, img := range mi.Images {
|
||||||
|
if slices.Contains(img.RepoTags, uniqueImage) {
|
||||||
|
hasImage = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, hasImage, "Machine %s with container should have image %s",
|
||||||
|
mi.Metadata.Machine, uniqueImage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// TODO: test deployments with unreachable machines. See https://github.com/psviderski/uncloud/issues/29.
|
// TODO: test deployments with unreachable machines. See https://github.com/psviderski/uncloud/issues/29.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ with [Let's Encrypt](https://letsencrypt.org/), and route requests to your servi
|
|||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
Caddy runs as a global service `caddy` on every machine in your cluster, listening on the host ports 80 (HTTP) and 443
|
By default, Caddy runs as a global service `caddy` on every machine in your cluster, listening on the host ports 80
|
||||||
(HTTPS).
|
(HTTP) and 443 (HTTPS).
|
||||||
|
|
||||||
It's deployed during cluster initialisation (`uc machine init`) unless you use the `--no-caddy` flag.
|
It's deployed during cluster initialisation (`uc machine init`) unless you use the `--no-caddy` flag.
|
||||||
See [Managing Caddy](3-managing-caddy.md) for deployment and customisation instructions.
|
See [Managing Caddy](3-managing-caddy.md) for deployment and customisation instructions.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Managing Caddy
|
# Managing Caddy
|
||||||
|
|
||||||
Caddy is automatically deployed as a global service `caddy` when you initialise a cluster with `uc machine init`. It
|
Caddy is automatically deployed as a global service `caddy` when you initialise a cluster with `uc machine init`. By
|
||||||
runs on every machine to handle incoming HTTP/HTTPS traffic and route it to your services.
|
default, it runs on every machine to handle incoming HTTP/HTTPS traffic and route it to your services.
|
||||||
|
|
||||||
## Checking status
|
## Checking status
|
||||||
|
|
||||||
@@ -37,6 +37,13 @@ Deploy a specific version or custom image:
|
|||||||
uc caddy deploy --image caddybuilds/caddy-cloudflare:2.10.2
|
uc caddy deploy --image caddybuilds/caddy-cloudflare:2.10.2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Deploy only to a specific machine or a subset of machines (comma-separated list):
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc caddy deploy --machine machine1
|
||||||
|
uc caddy deploy --machine machine2,machine3,machine4
|
||||||
|
```
|
||||||
|
|
||||||
Deploy with custom global configuration:
|
Deploy with custom global configuration:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
@@ -74,7 +81,7 @@ config that uses the DNS challenge with Cloudflare to obtain a wildcard TLS cert
|
|||||||
services:
|
services:
|
||||||
caddy:
|
caddy:
|
||||||
image: caddybuilds/caddy-cloudflare:2.10.2
|
image: caddybuilds/caddy-cloudflare:2.10.2
|
||||||
command: caddy run -c /config/Caddyfile --watch
|
command: caddy run -c /config/Caddyfile
|
||||||
environment:
|
environment:
|
||||||
CADDY_ADMIN: unix//run/caddy/admin.sock
|
CADDY_ADMIN: unix//run/caddy/admin.sock
|
||||||
env_file:
|
env_file:
|
||||||
@@ -90,6 +97,10 @@ services:
|
|||||||
x-caddy: Caddyfile
|
x-caddy: Caddyfile
|
||||||
deploy:
|
deploy:
|
||||||
mode: global
|
mode: global
|
||||||
|
# Optional: deploy only to specific machines.
|
||||||
|
# x-machines:
|
||||||
|
# - machine1
|
||||||
|
# - machine2
|
||||||
```
|
```
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
@@ -98,26 +109,26 @@ services:
|
|||||||
```caddyfile
|
```caddyfile
|
||||||
# Global options.
|
# Global options.
|
||||||
{
|
{
|
||||||
debug
|
debug
|
||||||
}
|
}
|
||||||
|
|
||||||
# A snippet that can be reused in custom Caddy configs for services (x-caddy).
|
# A snippet that can be reused in custom Caddy configs for services (x-caddy).
|
||||||
(my_snippet) {
|
(my_snippet) {
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|
||||||
# Obtain a wildcard TLS certificate for all subdomains of example.name using DNS challenge with Cloudflare.
|
# Obtain a wildcard TLS certificate for all subdomains of example.name using DNS challenge with Cloudflare.
|
||||||
# It will be used for services that publish ports with hostnames under example.name.
|
# It will be used for services that publish ports with hostnames under example.name.
|
||||||
*.example.com {
|
*.example.com {
|
||||||
tls {
|
tls {
|
||||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
}
|
}
|
||||||
respond "No host matched" 404
|
respond "No host matched" 404
|
||||||
}
|
}
|
||||||
|
|
||||||
# Expose an internal service that is not managed by Uncloud.
|
# Expose an internal service that is not managed by Uncloud.
|
||||||
internal.example.com {
|
internal.example.com {
|
||||||
reverse_proxy 192.168.1.100
|
reverse_proxy 192.168.1.100
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,8 +150,8 @@ uc deploy
|
|||||||
|
|
||||||
## Verifying config
|
## Verifying config
|
||||||
|
|
||||||
View the complete generated Caddyfile served by the `caddy` service. This is useful for debugging and verifying
|
View the complete generated Caddyfile served by the `caddy` service. This is useful for debugging and verifying custom
|
||||||
custom global and service-specific Caddy configs.
|
global and service-specific Caddy configs.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
uc caddy config
|
uc caddy config
|
||||||
@@ -155,26 +166,26 @@ Example output:
|
|||||||
# User-defined global config from service 'caddy'.
|
# User-defined global config from service 'caddy'.
|
||||||
# Global options.
|
# Global options.
|
||||||
{
|
{
|
||||||
debug
|
debug
|
||||||
}
|
}
|
||||||
|
|
||||||
# A snippet that can be reused in custom Caddy configs for services (x-caddy).
|
# A snippet that can be reused in custom Caddy configs for services (x-caddy).
|
||||||
(my_snippet) {
|
(my_snippet) {
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|
||||||
# Obtain a wildcard TLS certificate for all subdomains of example.name using DNS challenge with Cloudflare.
|
# Obtain a wildcard TLS certificate for all subdomains of example.name using DNS challenge with Cloudflare.
|
||||||
# It will be used for services that publish ports with hostnames under example.name.
|
# It will be used for services that publish ports with hostnames under example.name.
|
||||||
*.example.com {
|
*.example.com {
|
||||||
tls {
|
tls {
|
||||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
}
|
}
|
||||||
respond "No host matched" 404
|
respond "No host matched" 404
|
||||||
}
|
}
|
||||||
|
|
||||||
# Expose an internal service that is not managed by Uncloud.
|
# Expose an internal service that is not managed by Uncloud.
|
||||||
internal.example.com {
|
internal.example.com {
|
||||||
reverse_proxy 192.168.1.100
|
reverse_proxy 192.168.1.100
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check endpoint to verify Caddy reachability on this machine.
|
# Health check endpoint to verify Caddy reachability on this machine.
|
||||||
@@ -210,14 +221,14 @@ https://api.example.com {
|
|||||||
|
|
||||||
# User-defined config for service 'web'.
|
# User-defined config for service 'web'.
|
||||||
www.example.com {
|
www.example.com {
|
||||||
redir https://example.com{uri} permanent
|
redir https://example.com{uri} permanent
|
||||||
}
|
}
|
||||||
|
|
||||||
example.com {
|
example.com {
|
||||||
reverse_proxy 10.210.0.3:8000 {
|
reverse_proxy 10.210.0.3:8000 {
|
||||||
import common_proxy
|
import common_proxy
|
||||||
}
|
}
|
||||||
log
|
log
|
||||||
}
|
}
|
||||||
|
|
||||||
# Skipped invalid user-defined configs:
|
# Skipped invalid user-defined configs:
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Compose support matrix
|
||||||
|
|
||||||
|
Uncloud supports a subset of the [Compose specification](https://compose-spec.io/) with some extensions and limitations.
|
||||||
|
The following table shows the support status for main Compose features:
|
||||||
|
|
||||||
|
| Feature | Support Status | Notes |
|
||||||
|
|--------------------|--------------------|---------------------------------------------------------------------------------------|
|
||||||
|
| **Services** | | |
|
||||||
|
| `build` | ⚠️ Limited | Build context and Dockerfile |
|
||||||
|
| `command` | ✅ Supported | Override container command |
|
||||||
|
| `configs` | ✅ Supported | File-based and inline configs |
|
||||||
|
| `cpus` | ✅ Supported | CPU limit |
|
||||||
|
| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked |
|
||||||
|
| `dns` | ❌ Not supported | Built-in service discovery |
|
||||||
|
| `dns_search` | ❌ Not supported | Built-in service discovery |
|
||||||
|
| `entrypoint` | ✅ Supported | Override container entrypoint |
|
||||||
|
| `env_file` | ✅ Supported | Environment file |
|
||||||
|
| `environment` | ✅ Supported | Environment variables |
|
||||||
|
| `image` | ✅ Supported | Container image specification |
|
||||||
|
| `init` | ✅ Supported | Run init process in container |
|
||||||
|
| `labels` | ❌ Not supported | |
|
||||||
|
| `links` | ❌ Not supported | Use service names for communication |
|
||||||
|
| `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver |
|
||||||
|
| `mem_limit` | ✅ Supported | Memory limit |
|
||||||
|
| `mem_reservation` | ✅ Supported | Memory reservation |
|
||||||
|
| `mem_swappiness` | ❌ Not supported | |
|
||||||
|
| `memswap_limit` | ❌ Not supported | |
|
||||||
|
| `networks` | ❌ Not supported | All containers share cluster network |
|
||||||
|
| `ports` | ⚠️ Limited | `mode: host` only, use `x-ports` for HTTP/HTTPS |
|
||||||
|
| `privileged` | ✅ Supported | Run containers in privileged mode |
|
||||||
|
| `pull_policy` | ✅ Supported | `always`, `missing`, `never` |
|
||||||
|
| `secrets` | ❌ Not supported | Use configs or environment variables |
|
||||||
|
| `security_opt` | ❌ Not supported | |
|
||||||
|
| `storage_opt` | ❌ Not supported | |
|
||||||
|
| `user` | ✅ Supported | Set container user |
|
||||||
|
| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs |
|
||||||
|
| **Deploy** | | |
|
||||||
|
| `labels` | ❌ Not supported | |
|
||||||
|
| `mode` | ✅ Supported | Either `global` or `replicated` |
|
||||||
|
| `placement` | ❌ Not supported | Use `x-machines` extension |
|
||||||
|
| `replicas` | ✅ Supported | Number of container replicas |
|
||||||
|
| `resources` | ⚠️ Limited | CPU and memory limits only |
|
||||||
|
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |
|
||||||
|
| **Volumes** | | |
|
||||||
|
| Named volumes | ✅ Supported | Docker volumes |
|
||||||
|
| Bind mounts | ✅ Supported | Host path binding |
|
||||||
|
| Tmpfs mounts | ✅ Supported | In-memory filesystems |
|
||||||
|
| Volume labels | ✅ Supported | Custom labels |
|
||||||
|
| External volumes | ✅ Supported | Must exist before deployment |
|
||||||
|
| Volume drivers | ⚠️ Limited | Local driver only |
|
||||||
|
| **Configs** | | |
|
||||||
|
| File-based configs | ✅ Supported | Read from file |
|
||||||
|
| Inline configs | ✅ Supported | Defined in compose file |
|
||||||
|
| External configs | ❌ Not supported | Not supported |
|
||||||
|
| Short syntax | ❌ Not supported | Use long syntax only |
|
||||||
|
| **Extensions** | | |
|
||||||
|
| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration |
|
||||||
|
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints |
|
||||||
|
| `x-ports` | ✅ Uncloud-specific | Service port publishing |
|
||||||
|
|
||||||
|
### Legend
|
||||||
|
|
||||||
|
- ✅ **Supported**: Feature works as documented
|
||||||
|
- ⚠️ **Limited**: Partial support or with restrictions
|
||||||
|
- ❌ **Not supported**: Feature is not (yet) available
|
||||||
|
|
||||||
|
## Uncloud extensions
|
||||||
|
|
||||||
|
Uncloud provides several custom extensions to enhance the Compose experience:
|
||||||
|
|
||||||
|
### `x-ports`
|
||||||
|
|
||||||
|
Expose HTTP/HTTPS service ports via the Caddy reverse proxy, or bind TCP/UDP ports directly to the host:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
x-ports:
|
||||||
|
- 80/https
|
||||||
|
- example.com:80/https
|
||||||
|
- 8080:80/tcp@host
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Publishing services](../1-ingress/2-publishing-services.md) for more details.
|
||||||
|
|
||||||
|
### `x-caddy`
|
||||||
|
|
||||||
|
Custom Caddy reverse proxy configuration for a service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
x-caddy: |
|
||||||
|
example.com {
|
||||||
|
reverse_proxy {{upstreams 80}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Publishing services](../1-ingress/2-publishing-services.md) for more details.
|
||||||
|
|
||||||
|
### `x-machines`
|
||||||
|
|
||||||
|
Restrict which machines can run your service. If you deploy multiple replicas, Uncloud automatically spreads them across
|
||||||
|
the specified machines.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
x-machines:
|
||||||
|
- machine-1
|
||||||
|
- machine-2
|
||||||
|
# Short syntax for a single machine
|
||||||
|
# x-machines: machine-1
|
||||||
|
```
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Configs
|
||||||
|
|
||||||
|
Uncloud supports [Compose configs](https://github.com/compose-spec/compose-spec/blob/main/08-configs.md) for managing configuration files in your services. Configs allow you to store non-sensitive configuration data separately from your container images and mount them into containers at runtime.
|
||||||
|
See also [Docker Compose documentation](https://docs.docker.com/reference/compose-file/configs/) for the same feature.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Configs provide a way to:
|
||||||
|
|
||||||
|
- Store configuration files outside of container images
|
||||||
|
- Share configuration between multiple services
|
||||||
|
- Update configuration without rebuilding images
|
||||||
|
- Version control your configuration separately
|
||||||
|
|
||||||
|
## Defining Configs
|
||||||
|
|
||||||
|
Configs are defined in two places in your `compose.yaml`:
|
||||||
|
|
||||||
|
1. **Top-level `configs` section**: Define the config content
|
||||||
|
2. **Service-level `configs` section**: Mount configs into containers
|
||||||
|
|
||||||
|
## Top-level Configs
|
||||||
|
|
||||||
|
Define configs using either file-based or inline content:
|
||||||
|
|
||||||
|
### File-based Configs
|
||||||
|
|
||||||
|
Read configuration from a file on the (local/control) host where `uc deploy` is run:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configs:
|
||||||
|
nginx_config:
|
||||||
|
file: ./nginx.conf
|
||||||
|
app_config:
|
||||||
|
file: ./config/app.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
The file path is relative to the compose file location.
|
||||||
|
|
||||||
|
### Inline Configs
|
||||||
|
|
||||||
|
Define configuration content directly in the compose file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configs:
|
||||||
|
app_config:
|
||||||
|
content: |
|
||||||
|
database_url=postgres://localhost:5432/myapp
|
||||||
|
redis_url=redis://localhost:6379
|
||||||
|
# Variable interpolation is supported
|
||||||
|
log_level=${LOG_LEVEL:-info}
|
||||||
|
```
|
||||||
|
|
||||||
|
When using inline configs, [environment variable interpolation](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/) is supported so that you can customize configuration based on your deployment environment. Variables are resolved from the environment where `uc deploy` is executed.
|
||||||
|
|
||||||
|
## Service-level Config Mounts
|
||||||
|
|
||||||
|
Mount configs into containers using the long syntax:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
configs:
|
||||||
|
- source: nginx_config
|
||||||
|
target: /etc/nginx/nginx.conf
|
||||||
|
mode: 0644
|
||||||
|
- source: app_config
|
||||||
|
target: /app/config.properties
|
||||||
|
uid: "1000"
|
||||||
|
gid: "1000"
|
||||||
|
mode: 0600
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config Mount Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
| -------- | ------------------------------------------------- | ---------- |
|
||||||
|
| `source` | Name of the config (from top-level configs) | Required |
|
||||||
|
| `target` | Path where the config is mounted in the container | Required |
|
||||||
|
| `mode` | File permissions (octal format) | `0644` |
|
||||||
|
| `uid` | User ID that owns the file | Root user |
|
||||||
|
| `gid` | Group ID that owns the file | Root group |
|
||||||
|
|
||||||
|
## Complete Examples
|
||||||
|
|
||||||
|
### Example 1: Web Server with Custom Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
configs:
|
||||||
|
- source: nginx_conf
|
||||||
|
target: /etc/nginx/nginx.conf
|
||||||
|
x-ports:
|
||||||
|
- 80/https
|
||||||
|
|
||||||
|
configs:
|
||||||
|
nginx_conf:
|
||||||
|
file: ./nginx.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `nginx.conf` in the same directory as your compose file:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 200 'Hello from Uncloud!\n';
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Application with Multiple Config Files
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: node:18-alpine
|
||||||
|
command: ["node", "server.js"]
|
||||||
|
configs:
|
||||||
|
- source: app_config
|
||||||
|
target: /app/config.json
|
||||||
|
mode: 0644
|
||||||
|
- source: database_config
|
||||||
|
target: /app/database.json
|
||||||
|
uid: "1000"
|
||||||
|
gid: "1000"
|
||||||
|
mode: 0600
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
|
||||||
|
configs:
|
||||||
|
app_config:
|
||||||
|
content: |
|
||||||
|
{
|
||||||
|
"port": 3000,
|
||||||
|
"logLevel": "info",
|
||||||
|
"features": {
|
||||||
|
"analytics": true,
|
||||||
|
"cache": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database_config:
|
||||||
|
file: ./configs/database.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation details
|
||||||
|
|
||||||
|
Here are the key characteristics of the configs feature implementation:
|
||||||
|
|
||||||
|
- **Client-side processing**: When you run `uc deploy`, the Uncloud CLI reads config files from your local machine and includes their content in the service specification.
|
||||||
|
|
||||||
|
- **Content transfer**: Config content (both file-based and inline) is sent to the Uncloud daemon via gRPC as part of the deployment request.
|
||||||
|
|
||||||
|
- **Container deployment**: During container creation, configs are copied inside the container.
|
||||||
|
|
||||||
|
- **File lifecycle**: Config files exist only for the lifetime of the container. When a container is removed, its config files are cleaned up automatically.
|
||||||
|
|
||||||
|
- **Per-container isolation**: Each container gets its own copy of config files.
|
||||||
|
|
||||||
|
- **Atomic updates**: Config changes require redeployment, ensuring consistency across all replicas.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
- **Sensitive Data**: Don't put secrets in configs. Use environment variables or external secret management
|
||||||
|
- **File Permissions**: Set appropriate `mode`, `uid`, and `gid` for sensitive config files
|
||||||
|
- **Version Control**: Be careful about committing sensitive configuration files to git
|
||||||
|
|
||||||
|
### Config Sharing
|
||||||
|
|
||||||
|
Configs can be shared across multiple services:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
configs:
|
||||||
|
- source: shared_config
|
||||||
|
target: /etc/app/config.yaml
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: myapi
|
||||||
|
configs:
|
||||||
|
- source: shared_config
|
||||||
|
target: /app/config.yaml
|
||||||
|
|
||||||
|
configs:
|
||||||
|
shared_config:
|
||||||
|
content: |
|
||||||
|
environment: production
|
||||||
|
debug: false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- **External configs**: Not supported. All configs must be defined in the compose file
|
||||||
|
- **Short syntax**: Not yet supported. Use the long syntax with `source` and `target`
|
||||||
|
- **Config updates**: Changing config content requires redeployment to take effect
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Config File Not Found
|
||||||
|
|
||||||
|
If you get an error about config file not found:
|
||||||
|
|
||||||
|
1. Check the file path is correct relative to the compose file
|
||||||
|
2. Ensure the file exists and is readable
|
||||||
|
3. Verify file permissions
|
||||||
|
|
||||||
|
### Permission Denied
|
||||||
|
|
||||||
|
If containers can't read config files:
|
||||||
|
|
||||||
|
1. Check the `mode` setting allows read access
|
||||||
|
2. Verify `uid` and `gid` match the container's user
|
||||||
|
3. Ensure the container user has permission to access the target directory
|
||||||
|
|
||||||
|
### Config Not Updating
|
||||||
|
|
||||||
|
If config changes don't take effect:
|
||||||
|
|
||||||
|
1. Run `uc deploy` to redeploy with new config content
|
||||||
|
2. Check that you're modifying the correct config file
|
||||||
|
3. Verify the config is properly mounted in the container with `docker exec <service> cat <config-path>` on the remote machine
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
label: Compose features
|
||||||
|
collapsed: true # keep the category closed by default
|
||||||
|
link:
|
||||||
|
type: generated-index
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Internal DNS
|
||||||
|
|
||||||
|
Services can be addressed on the internal WireGuard network by service name, service ID, or a machine-scoped service name:
|
||||||
|
|
||||||
|
## Service name
|
||||||
|
```
|
||||||
|
$ nslookup nats.internal
|
||||||
|
Server: 127.0.0.11
|
||||||
|
Address: 127.0.0.11#53
|
||||||
|
|
||||||
|
Name: nats.internal
|
||||||
|
Address: 10.210.0.2
|
||||||
|
Name: nats.internal
|
||||||
|
Address: 10.210.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
$ nslookup worker.internal
|
||||||
|
Server: 127.0.0.11
|
||||||
|
Address: 127.0.0.11#53
|
||||||
|
|
||||||
|
Name: worker.internal
|
||||||
|
Address: 10.210.0.3
|
||||||
|
Name: worker.internal
|
||||||
|
Address: 10.210.0.4
|
||||||
|
Name: worker.internal
|
||||||
|
Address: 10.210.1.3
|
||||||
|
Name: worker.internal
|
||||||
|
Address: 10.210.1.4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service ID
|
||||||
|
```
|
||||||
|
$ nslookup 3ecb3a8bbec5fd3f46efb056a934714a.internal
|
||||||
|
Server: 127.0.0.11
|
||||||
|
Address: 127.0.0.11#53
|
||||||
|
|
||||||
|
Name: 3ecb3a8bbec5fd3f46efb056a934714a.internal
|
||||||
|
Address: 10.210.0.4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Machine ID scoped service name
|
||||||
|
```
|
||||||
|
$ nslookup 0903f0ee483aa97d559eeeaac5e22283.m.nats.internal
|
||||||
|
Server: 127.0.0.11
|
||||||
|
Address: 127.0.0.11#53
|
||||||
|
|
||||||
|
Name: nats.internal
|
||||||
|
Address: 10.210.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
$ nslookup 0903f0ee483aa97d559eeeaac5e22283.m.worker.internal
|
||||||
|
Server: 127.0.0.11
|
||||||
|
Address: 127.0.0.11#53
|
||||||
|
|
||||||
|
Name: worker.internal
|
||||||
|
Address: 10.210.1.3
|
||||||
|
Name: worker.internal
|
||||||
|
Address: 10.210.1.4
|
||||||
|
```
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Container ENV Variables
|
||||||
|
|
||||||
|
- `UNCLOUD_MACHINE_ID`
|
||||||
|
- Machine ID the service container is running on.
|
||||||
|
- Example: `UNCLOUD_MACHINE_ID=0903f0ee483aa97d559eeeaac5e22283`
|
||||||
|
- `HOSTNAME`
|
||||||
|
- Service name with unique suffix.
|
||||||
|
- Example: `HOSTNAME=worker-c1zd`
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
label: Services
|
||||||
|
collapsed: true # keep the category closed by default
|
||||||
|
link:
|
||||||
|
type: generated-index
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
# uc
|
# uc
|
||||||
|
|
||||||
A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
A CLI tool for managing Uncloud resources such as machines, services, and volumes.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port] or tcp://host:port
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
-h, --help help for uc
|
-h, --help help for uc
|
||||||
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
@@ -18,6 +18,8 @@ A CLI tool for managing Uncloud resources such as clusters, machines, and servic
|
|||||||
* [uc ctx](uc_ctx.md) - Switch between different cluster contexts. Contains subcommands to manage contexts.
|
* [uc ctx](uc_ctx.md) - Switch between different cluster contexts. Contains subcommands to manage contexts.
|
||||||
* [uc deploy](uc_deploy.md) - Deploy services from a Compose file.
|
* [uc deploy](uc_deploy.md) - Deploy services from a Compose file.
|
||||||
* [uc dns](uc_dns.md) - Manage cluster domain in Uncloud DNS.
|
* [uc dns](uc_dns.md) - Manage cluster domain in Uncloud DNS.
|
||||||
|
* [uc image](uc_image.md) - Manage images on machines in the cluster.
|
||||||
|
* [uc images](uc_images.md) - List images on machines in the cluster.
|
||||||
* [uc inspect](uc_inspect.md) - Display detailed information on a service.
|
* [uc inspect](uc_inspect.md) - Display detailed information on a service.
|
||||||
* [uc ls](uc_ls.md) - List services.
|
* [uc ls](uc_ls.md) - List services.
|
||||||
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ uc build [FLAGS] [SERVICE...] [flags]
|
|||||||
## Options inherited from parent commands
|
## Options inherited from parent commands
|
||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port] or tcp://host:port
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes.
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ Manage Caddy reverse proxy service.
|
|||||||
## Options inherited from parent commands
|
## Options inherited from parent commands
|
||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port] or tcp://host:port
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes.
|
||||||
* [uc caddy config](uc_caddy_config.md) - Show the current Caddy configuration (Caddyfile).
|
* [uc caddy config](uc_caddy_config.md) - Show the current Caddy configuration (Caddyfile).
|
||||||
* [uc caddy deploy](uc_caddy_deploy.md) - Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
|
* [uc caddy deploy](uc_caddy_deploy.md) - Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ uc caddy config [flags]
|
|||||||
## Options inherited from parent commands
|
## Options inherited from parent commands
|
||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port] or tcp://host:port
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ uc caddy deploy [flags]
|
|||||||
## Options inherited from parent commands
|
## Options inherited from parent commands
|
||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port] or tcp://host:port
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ uc ctx [flags]
|
|||||||
## Options inherited from parent commands
|
## Options inherited from parent commands
|
||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file.
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port] or tcp://host:port
|
Format: [ssh://]user@host[:port] or tcp://host:port
|
||||||
--uncloud-config string Path to the Uncloud configuration file. (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as clusters, machines, and services.
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes.
|
||||||
* [uc ctx ls](uc_ctx_ls.md) - List available cluster contexts.
|
* [uc ctx ls](uc_ctx_ls.md) - List available cluster contexts.
|
||||||
* [uc ctx use](uc_ctx_use.md) - Switch to a different cluster context.
|
* [uc ctx use](uc_ctx_use.md) - Switch to a different cluster context.
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user