Compare commits

...
72 Commits
Author SHA1 Message Date
Pasha Sviderski dc569175ef docs: add info that caddy could be deployed on specific machines 2025-10-13 20:40:11 +10:00
Pasha Sviderski 6ba98e4576 fix: 'uc image push' closed network connection error if takes >10s (fixes #141) 2025-10-13 20:19:46 +10:00
Pasha Sviderski 75b932b66e docs: regenerate CLI reference docs 2025-10-10 18:57:15 +10:00
Pasha Sviderski 5aa85ab501 fix(push): always provide encoded empty auth config to work around panic in Docker 2025-10-09 15:14:10 +10:00
Pasha Sviderski 521ccf2026 chore(push): run unregistry only if containerd image store enabled for docker 2025-10-09 12:48:51 +10:00
Pasha Sviderski e8b4dd0bd1 chore(push): fail 'image push' when containerd image store not used on target machine 2025-10-09 12:11:18 +10:00
Pasha Sviderski 265042e2c8 lint 2025-10-08 16:38:40 +10:00
Justin BradfordandGitHub 1c85112d4f docs: Rough notes for "services" docs with examples of internal DNS lookups and container ENV (#136) 2025-10-08 16:35:52 +10:00
Pasha Sviderski 0fc5032b27 feat(images): add IN USE column to highlight which images are used by any container 2025-10-08 15:43:54 +10:00
Pasha Sviderski 893ab1f9f5 fix: negotiate docker client API version in e2e test 2025-10-08 14:18:11 +10:00
Pasha Sviderski a30ed600b6 chore: update docker and compose Go dependencies to the latest versions 2025-10-08 13:56:58 +10:00
Pasha Sviderski a2c6cbe633 chore(images): filter images by name with wildcard pattern using Docker filter 2025-10-08 12:33:13 +10:00
Pasha Sviderski 1f9c2c873d feat(images): list images filtered by name 2025-10-07 20:39:31 +10:00
Pasha Sviderski 509c9eb7d3 chore(images): format platforms as pills, sort images by name 2025-10-07 19:14:40 +10:00
Pasha Sviderski fba03b3df7 feat(images): list image platforms and do not group images by machine 2025-10-07 17:06:52 +10:00
Pasha Sviderski c6d18f5d49 chore: NAME -> IMAGE column name 2025-10-07 15:12:05 +10:00
3e804d9ecc feat: set UNCLOUD_MACHINE_ID in service container ENV (#135)
* Set machine id in service container ENV

* Add machine id to clone of the container's env map to avoid exposing serialization format

* fix: panic on setting UNCLOUD_MACHINE_ID env var when Env is nil in the spec

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
2025-10-07 14:12:00 +10:00
Pasha Sviderski 784d86d8e8 fix: test build for docker server 2025-10-03 21:10:02 +10:00
Pasha Sviderski 3bac1e61b9 chore: go mod tidy 2025-10-03 20:54:59 +10:00
Pasha Sviderski 6d2e100d63 feat(images): 'uc image ls' and 'uc images' (alias) commands to list images on machines 2025-10-03 20:53:30 +10:00
Pasha Sviderski cce81ecaad chore(images): simplifies ListImages service, server, and client to only list Docker images and not directly access containerd 2025-10-03 18:25:39 +10:00
Pasha Sviderski f8bf6bccd3 test: basic e2e test for ListImages 2025-10-03 16:55:20 +10:00
Pasha Sviderski 82fd9744bf chore: remove unused imports 2025-10-03 15:03:19 +10:00
Pasha Sviderski cdff036935 feat(images): gRPC client and server for ListImages 2025-10-03 14:23:18 +10:00
Pasha Sviderski 458d282357 chore: correct comment and add TODO to move machine resolution to grpc-proxy router 2025-10-02 20:12:45 +10:00
Pasha Sviderski 59074a275f chore(images): initialise Docker service with containerd client 2025-10-02 15:51:54 +10:00
Pasha Sviderski 92975eec51 feat(images): add gRPC proto ListImages 2025-10-02 12:50:53 +10:00
Pasha Sviderski e0d11a8abc chore: configure Docker to use containerd image store by default (closes #129) 2025-10-01 15:00:19 +10:00
Pasha Sviderski fa004d63bb chore: add firewall rule to allow cluster machines to push to other machines unregistry 2025-10-01 12:34:03 +10:00
Pasha Sviderski a217643ac9 chore: shorten socat proxy container progress message 2025-09-30 19:44:54 +10:00
Pasha Sviderski fc3297c7b7 chore: go mod tidy 2025-09-30 19:31:14 +10:00
Pasha Sviderski 684f0d38ee feat(push): '--platform all' to push image to all machines in cluster 2025-09-30 19:23:01 +10:00
Pasha Sviderski 332e7bca90 feat(push): --platform flag to push a specific platform of a multi-platform image 2025-09-30 18:59:03 +10:00
Pasha Sviderski ae458d260f chore: refactor duplication 2025-09-30 15:42:01 +10:00
Pasha Sviderski 6b758c810e docs: image push example 2025-09-30 15:24:40 +10:00
Pasha Sviderski ffed906cef feat(push): 'image push' to single or multiple machines via unregistry proxy 2025-09-30 15:18:26 +10:00
Pasha Sviderski bcc504b8cc chore: internal Docker client PushImage 2025-09-30 13:58:34 +10:00
Anton Ovchinnikov 782f4cb20b chore: Update AI.md with instructions around tests and docs 2025-09-29 00:02:58 +02:00
Pasha Sviderski aebd2ac232 docs: review and update Compose support matrix 2025-09-26 22:42:07 +10:00
Pasha Sviderski 2969b40ad4 chore: return a proxy dialer for SSH connections 2025-09-26 21:24:56 +10:00
Pasha Sviderski e0045c7570 chore: refactor gRPC docker client 2025-09-26 21:24:56 +10:00
Pasha Sviderski bc6f2fd49d chore: fix docker client CreateContainerWithImagePull, add WaitPortPublished 2025-09-26 21:24:56 +10:00
Pasha Sviderski cd25b973ea fix: docker client PullImage 2025-09-26 21:24:56 +10:00
Pasha Sviderski 27bbbe7186 chore: internal docker package with handy PullImage and CreateContainerWithImagePull methods 2025-09-26 21:24:56 +10:00
Pasha Sviderski 20cf98d42e fix: return an error if 'uc rm' tries to remove a service that doesn't exist 2025-09-26 21:24:56 +10:00
Anton OvchinnikovandGitHub 63c4de512e feat: Initial support for Compose configs (#116) 2025-09-26 21:24:10 +10:00
Anton Ovchinnikov 337c15de35 chore: Add node to Mise 2025-09-24 23:16:36 +02:00
Justin BradfordandGitHub 2d361d3ee6 Adding machine.service.internal dns entries (#123) 2025-09-24 20:06:54 +10:00
Pasha Sviderski 977fdf3cc3 chore: add Proxy for proxing local connections to a remote TCP address using the dialer 2025-09-24 19:52:37 +10:00
Pasha Sviderski 9f6880b701 feat(push): stub for 'image push' command and Dialer interface for cluster connectors 2025-09-23 16:54:47 +10:00
Pasha Sviderski 1a1afece7d fix: e2e test rebind the registry port to unoccupied 5001 2025-09-23 15:26:36 +10:00
Pasha Sviderski 08f233c5b1 fix: auto-detect containerd.sock path and conditionally start unregistry 2025-09-23 15:10:34 +10:00
Pasha Sviderski 9438654d99 docs: fix comment in schema.sql 2025-09-23 12:21:45 +10:00
Pasha Sviderski 60926f64cc feat: show IMAGE column when listing services (uc ls) #13 2025-09-22 14:03:45 +10:00
Pasha Sviderski b6b8286118 feat: run embedded unregistry on machine IP in uncloudd daemon 2025-09-19 20:43:20 +10:00
Pasha Sviderski 8f906cfd95 fix: do not auto-confirm deploy plan on CI (no TTY), require explicit --yes or UNCLOUD_AUTO_CONFIRM=true 2025-09-18 12:12:06 +10:00
Pasha Sviderski da03ed8ece docs: generate CLI reference docs 2025-09-17 15:11:36 +10:00
Pasha Sviderski 7239cd3a6b docs: clarify 'machine init' help, --context flag description, add examples 2025-09-17 15:10:59 +10:00
Pasha Sviderski 3db07ca2d4 feat: allow setting config and connection with UNCLOUD_CONFIG and UNCLOUD_CONNECT env vars 2025-09-16 10:17:44 +10:00
Pasha Sviderski f8fe3c7af3 feat: --no-install flag for 'machine init/add' to skip installing Uncloud daemon and dependencies #122 2025-09-15 20:44:35 +10:00
Pasha Sviderski b6e9766f40 lint: gRPC status.Error for static error messages 2025-09-15 17:11:12 +10:00
Pasha Sviderski 1f62e30a08 chore: update mise checksums for linux-x64 2025-09-13 22:02:11 +10:00
Pasha Sviderski 88f05ffd75 chore: update mise checksums for macos-arm64 2025-09-13 21:50:40 +10:00
Pasha Sviderski 375bb385a2 chore: bump golangci-lint to 2.4.0 on CI 2025-09-13 21:44:45 +10:00
Pasha Sviderski 51840e0366 chore: bump golangci-lint to 2.4.0 2025-09-13 21:20:48 +10:00
Pasha Sviderski 831c581e0f feat: try each cluster connection in order until one succeeds, add progress spinner 2025-09-13 21:12:20 +10:00
Pasha Sviderski e1734a1aa6 chore: bump Go to 1.25.1 2025-09-13 21:06:17 +10:00
Pasha Sviderski 84376bb34c chore: mise.lock include checksums for linux-x64 2025-09-12 23:18:45 +10:00
Pasha Sviderski 6d0b1dc1ee chore bump mise version on CI to 2025.9.6 2025-09-12 22:58:26 +10:00
Pasha Sviderski bc078472d4 feat: add -y|--yes flag for 'us deploy' to auto-confirm deployment plan 2025-09-12 22:52:06 +10:00
Pasha Sviderski 8a273dbf63 chore: rename cluster -> context option in commands for consistency 2025-09-11 20:21:07 +10:00
Pasha Sviderski 001acfc6a5 docs: remove --watch flag from compose file for deploying caddy 2025-09-10 17:32:15 +10:00
135 changed files with 5314 additions and 1151 deletions
+2 -2
View File
@@ -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 }}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+18 -4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -8
View File
@@ -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
View File
@@ -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 {
+312
View File
@@ -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()
}
+100
View File
@@ -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))
}
+19
View File
@@ -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
}
+19
View File
@@ -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
}
+13 -6
View File
@@ -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 {
+24 -4
View File
@@ -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 {
+3 -3
View File
@@ -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
View File
@@ -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(),
+3 -3
View File
@@ -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
View File
@@ -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)
} }
} }
+4 -4
View File
@@ -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
} }
+3 -3
View File
@@ -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)
} }
+3 -3
View File
@@ -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)
} }
+121 -90
View File
@@ -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
+324 -329
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -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
View File
@@ -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 {
+10
View File
@@ -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
+189
View File
@@ -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())),
)
}
+13
View File
@@ -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
View File
@@ -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()))
}
+4
View File
@@ -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.
+70
View File
@@ -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()
}
}
}
+132
View File
@@ -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 -1
View File
@@ -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;
+38
View File
@@ -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",
+2 -2
View File
@@ -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{
+30 -2
View File
@@ -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()
+1 -1
View File
@@ -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{
+2
View File
@@ -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
) )
+3 -2
View File
@@ -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)
} }
+6 -1
View File
@@ -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.
+29 -34
View File
@@ -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,
}) })
+6 -6
View File
@@ -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))
} }
+229 -52
View File
@@ -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())
+51 -2
View File
@@ -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
}
+5 -2
View File
@@ -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")
} }
+21 -6
View File
@@ -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)
} }
} }
+59 -4
View File
@@ -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 {
+1 -1
View File
@@ -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"')),
+132
View File
@@ -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))
}
}
}
}
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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
} }
+125
View File
@@ -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
}
+294
View File
@@ -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)
})
}
}
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+29
View File
@@ -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
View File
@@ -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
+5 -1
View File
@@ -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
} }
+75
View File
@@ -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
}
+114
View File
@@ -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))
}
-2
View File
@@ -1,7 +1,5 @@
package compose package compose
// TODO: make compose, cli, and api packages public.
import ( import (
"context" "context"
"fmt" "fmt"
+10 -1
View File
@@ -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
View File
@@ -0,0 +1 @@
test config content
+2
View File
@@ -0,0 +1,2 @@
user nginx;
worker_processes auto;
+11
View File
@@ -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
View File
@@ -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
} }
+5
View File
@@ -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
View File
@@ -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 {
+18
View File
@@ -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
View File
@@ -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(),
}
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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! 🎉"
+2 -2
View File
@@ -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,
}, },
+110
View File
@@ -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)
})
}
+227
View File
@@ -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)
})
}
+21
View File
@@ -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
+105
View File
@@ -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
}
+70
View File
@@ -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
+5 -3
View File
@@ -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.
+3 -3
View File
@@ -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.
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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