mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da09d22b47 | ||
|
|
37f543b905 | ||
|
|
e5f96509bf | ||
|
|
2f35ac6498 | ||
|
|
f75afa0cec | ||
|
|
0f38b128fa | ||
|
|
535b91fe52 | ||
|
|
5c8beb8bd3 | ||
|
|
2b4da1e6fe | ||
|
|
be8b4f079f | ||
|
|
59b96cc01f | ||
|
|
4c77fe2cfd | ||
|
|
3b89af4a11 | ||
|
|
1abadaef50 | ||
|
|
3b1ce42dd7 | ||
|
|
1640b4c433 | ||
|
|
44549c00fd | ||
|
|
26f34df3e6 | ||
|
|
89fb9efcfa | ||
|
|
638f320959 | ||
|
|
3f2e60ea74 | ||
|
|
8e0a24e781 | ||
|
|
e3e39dde32 | ||
|
|
fc61e2fc07 | ||
|
|
2a0d31cec0 | ||
|
|
2f55af0cab | ||
|
|
971b21d6c1 | ||
|
|
b651d95183 | ||
|
|
d45c1b6869 | ||
|
|
a5fe9fbfb2 | ||
|
|
4590a516ad | ||
|
|
5f9ed6dacf | ||
|
|
e97497d737 | ||
|
|
b4bf1d17d0 | ||
|
|
cfb4693286 | ||
|
|
fd3facb6b0 | ||
|
|
bd03bb9bfa | ||
|
|
eec728620b | ||
|
|
d2dd5e3c89 | ||
|
|
2bfcce7803 | ||
|
|
1ec5184f21 | ||
|
|
23dfe45b2f | ||
|
|
d9e906a77e | ||
|
|
51f7ebf3bf | ||
|
|
ae04dea808 | ||
|
|
009c0e1b4c | ||
|
|
18d38e96b4 | ||
|
|
12c86f1dd4 | ||
|
|
4df1820f96 | ||
|
|
6f9ac1fcc7 | ||
|
|
6a98acbe79 | ||
|
|
b0059af9b1 | ||
|
|
7edd8f2976 | ||
|
|
735e36012a | ||
|
|
69ebce57e9 | ||
|
|
365a5fc0b5 | ||
|
|
624caf037b | ||
|
|
ff1a8fbcf9 | ||
|
|
3aa8fead76 | ||
|
|
4075f85b72 | ||
|
|
59f6fa75ca | ||
|
|
8b66a11394 | ||
|
|
fa3d992dbc | ||
|
|
a7d8afb052 | ||
|
|
0d820bc75a | ||
|
|
6a4e63dbd4 | ||
|
|
7699b530c2 | ||
|
|
f1abbbea6d | ||
|
|
683ad22358 | ||
|
|
8f00e0082e | ||
|
|
c34542a9d8 | ||
|
|
88db1cb6a9 | ||
|
|
dfa6e001c5 | ||
|
|
f49ec465d0 | ||
|
|
1ffe9acdd7 | ||
|
|
e35e69b4be | ||
|
|
11c2e641f1 | ||
|
|
bb97537df7 | ||
|
|
9f4b8179b5 | ||
|
|
f7b079c4f2 | ||
|
|
3648187219 | ||
|
|
93346301fb | ||
|
|
cc86c8660c | ||
|
|
2963436538 | ||
|
|
6e326277bb | ||
|
|
469d6fadcd | ||
|
|
5f214247ca | ||
|
|
0b8495e964 | ||
|
|
6068e24f4f | ||
|
|
7861d56dff | ||
|
|
7bc69b9a9e | ||
|
|
38800924ed | ||
|
|
cf7e333579 | ||
|
|
769f5e47c3 | ||
|
|
6e1165ec57 | ||
|
|
8d16da596c | ||
|
|
d6c06828b3 | ||
|
|
fe93d91894 | ||
|
|
3f70018e7c | ||
|
|
70e9385032 | ||
|
|
db3016fe89 | ||
|
|
c56385602e | ||
|
|
fbbf839258 | ||
|
|
eee28e2ead | ||
|
|
ef57b62625 | ||
|
|
e854bbb3e1 |
@@ -0,0 +1,20 @@
|
|||||||
|
# EditorConfig is awesome: https://editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Default settings for all files
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# Settings for Go files
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
# Settings for Bash scripts
|
||||||
|
[*.sh]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [psviderski]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 525 KiB |
@@ -0,0 +1,67 @@
|
|||||||
|
name: Go Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
- "test/**"
|
||||||
|
- "release/**"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- ".github/**"
|
||||||
|
- "**.go"
|
||||||
|
- "**.proto"
|
||||||
|
- "go.*"
|
||||||
|
- "Makefile"
|
||||||
|
- "scripts/**"
|
||||||
|
- "test/**"
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||||
|
with:
|
||||||
|
go-version: "1.23.2"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code ||
|
||||||
|
(echo "go.mod or go.sum has changed. Please run 'go mod tidy' and commit the changes." && exit 1)
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: make test
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
check-protobuf:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 10
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
# Run on more platforms so that mise lockfile checksums are checked/generated for all platforms.
|
||||||
|
# See https://github.com/jdx/mise/issues/4276 for details.
|
||||||
|
os: [ubuntu-latest, macos-latest]
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Install Mise
|
||||||
|
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
|
||||||
|
with:
|
||||||
|
version: "2025.6.5"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Generate Protobuf files
|
||||||
|
run: |
|
||||||
|
make proto
|
||||||
|
# check if the generated files are up to date
|
||||||
|
git diff --exit-code ||
|
||||||
|
(echo "Generated protobuf files are not up to date. Please run 'make proto' and commit the changes." && exit 1)
|
||||||
@@ -30,6 +30,7 @@ go.work.sum
|
|||||||
/data/
|
/data/
|
||||||
/dist/
|
/dist/
|
||||||
/uc
|
/uc
|
||||||
|
/tmp
|
||||||
|
|
||||||
# Web
|
# Web
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ builds:
|
|||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/psviderski/uncloud/internal/version.version={{ .Version }}
|
||||||
|
|
||||||
- id: uncloudd
|
- id: uncloudd
|
||||||
main: ./cmd/uncloudd
|
main: ./cmd/uncloudd
|
||||||
@@ -35,6 +37,8 @@ builds:
|
|||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/psviderski/uncloud/internal/version.version={{ .Version }}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: uncloud
|
- id: uncloud
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
[tools.go]
|
||||||
|
version = "1.23.10"
|
||||||
|
backend = "core:go"
|
||||||
|
|
||||||
|
[tools.go.checksums]
|
||||||
|
"go1.23.10.darwin-arm64.tar.gz" = "sha256:25c64bfa8a8fd8e7f62fb54afa4354af8409a4bb2358c2699a1003b733e6fce5"
|
||||||
|
"go1.23.10.linux-amd64.tar.gz" = "sha256:535f9f81802499f2a7dbfa70abb8fda3793725fcc29460f719815f6e10b5fd60"
|
||||||
|
|
||||||
|
[tools.protoc]
|
||||||
|
version = "27.3"
|
||||||
|
backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||||
|
|
||||||
|
[tools.protoc.checksums]
|
||||||
|
"protoc-27.3-linux-x86_64.zip" = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
|
||||||
|
"protoc-27.3-osx-aarch_64.zip" = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go]
|
||||||
|
version = "1.34.2"
|
||||||
|
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go.checksums]
|
||||||
|
"protoc-gen-go.v1.34.2.darwin.arm64.tar.gz" = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
|
||||||
|
"protoc-gen-go.v1.34.2.linux.amd64.tar.gz" = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc]
|
||||||
|
version = "1.5.1"
|
||||||
|
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc.checksums]
|
||||||
|
"protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz" = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
|
||||||
|
"protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz" = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
|
[settings]
|
||||||
|
experimental = true
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
go = "1.23"
|
go = "1.23"
|
||||||
|
protoc = "27.3"
|
||||||
|
protoc-gen-go = "1.34.2"
|
||||||
|
protoc-gen-go-grpc = "1.5.1"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
_.file = ".env"
|
_.file = ".env"
|
||||||
|
|||||||
+12
-3
@@ -17,13 +17,22 @@ RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd cmd/uncloudd/main
|
|||||||
|
|
||||||
|
|
||||||
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
||||||
RUN wget -q -O /tmp/corrosion.tar.gz \
|
ARG TARGETARCH
|
||||||
https://github.com/psviderski/corrosion/releases/latest/download/corrosion-aarch64-unknown-linux-gnu.tar.gz \
|
|
||||||
|
RUN CORROSION_ARCH=$(case "${TARGETARCH}" in \
|
||||||
|
"amd64") echo "x86_64" ;; \
|
||||||
|
"arm64") echo "aarch64" ;; \
|
||||||
|
*) echo "Architecture '${TARGETARCH}' not supported" >&2 && exit 1 ;; \
|
||||||
|
esac) \
|
||||||
|
&& wget -q -O /tmp/corrosion.tar.gz \
|
||||||
|
"https://github.com/psviderski/corrosion/releases/latest/download/corrosion-${CORROSION_ARCH}-unknown-linux-gnu.tar.gz" \
|
||||||
&& tar -xzf /tmp/corrosion.tar.gz -C /tmp \
|
&& tar -xzf /tmp/corrosion.tar.gz -C /tmp \
|
||||||
&& install /tmp/corrosion /usr/local/bin/corrosion \
|
&& install /tmp/corrosion /usr/local/bin/corrosion \
|
||||||
&& rm /tmp/corrosion.tar.gz /tmp/corrosion
|
&& rm /tmp/corrosion.tar.gz /tmp/corrosion
|
||||||
|
|
||||||
FROM chainguard/wolfi-base:latest AS corrosion
|
# Beware that more modern images like chainguard/wolfi-base build glibc with flags that require newer CPU features,
|
||||||
|
# e.g. x86-64-v2. So such image may fail with "Fatal glibc error: CPU does not support x86-64-v2" on older CPUs.
|
||||||
|
FROM gcr.io/distroless/cc-debian12:latest AS corrosion
|
||||||
COPY --from=corrosion-download /usr/local/bin/corrosion /usr/local/bin/corrosion
|
COPY --from=corrosion-download /usr/local/bin/corrosion /usr/local/bin/corrosion
|
||||||
CMD ["corrosion", "agent"]
|
CMD ["corrosion", "agent"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
|
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
|
||||||
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
|
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
|
||||||
|
DOCS_IMAGE ?= ghcr.io/psviderski/uncloud-docs:latest
|
||||||
|
|
||||||
update-dev:
|
update-dev:
|
||||||
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
|
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
|
||||||
@@ -36,6 +37,10 @@ demo-reset:
|
|||||||
ssh root@5.223.45.199 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
ssh root@5.223.45.199 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
||||||
ssh spy@192.168.40.243 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
ssh spy@192.168.40.243 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
||||||
|
|
||||||
|
.PHONY: ucind-cluster
|
||||||
|
ucind-cluster:
|
||||||
|
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
|
||||||
|
|
||||||
.PHONY: proto
|
.PHONY: proto
|
||||||
proto:
|
proto:
|
||||||
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
@@ -45,14 +50,45 @@ proto:
|
|||||||
corrosion-image:
|
corrosion-image:
|
||||||
docker build -t "$(CORROSION_IMAGE)" --target corrosion .
|
docker build -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||||
|
|
||||||
.PHONY: corrosion-image-push
|
.PHONY: corrosion-multiarch-image-push
|
||||||
corrosion-image-push: corrosion-image
|
corrosion-multiarch-image-push:
|
||||||
docker push "$(CORROSION_IMAGE)"
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||||
|
|
||||||
.PHONY: ucind-image
|
.PHONY: ucind-image
|
||||||
ucind-image:
|
ucind-image:
|
||||||
docker build -t "$(UCIND_IMAGE)" --target ucind .
|
docker build -t "$(UCIND_IMAGE)" --target ucind .
|
||||||
|
|
||||||
.PHONY: ucind-image-push
|
.PHONY: ucind-multiarch-image-push
|
||||||
ucind-image-push: ucind-image
|
ucind-multiarch-image-push:
|
||||||
docker push "$(UCIND_IMAGE)"
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
ifeq ($(TEST_NAME),)
|
||||||
|
go test -count=1 -v ./...
|
||||||
|
else
|
||||||
|
go test -count=1 -v -run ^$(TEST_NAME)$$ ./...
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: test-clean
|
||||||
|
test-clean:
|
||||||
|
@CONTAINERS=$$(docker ps --filter "name=ucind-test" -q); \
|
||||||
|
if [ -n "$$CONTAINERS" ]; then \
|
||||||
|
echo "Killing containers..."; \
|
||||||
|
docker kill $$CONTAINERS; \
|
||||||
|
fi; \
|
||||||
|
CONTAINERS_STOPPED=$$(docker ps -a --filter "name=ucind-test" -q); \
|
||||||
|
if [ -n "$$CONTAINERS_STOPPED" ]; then \
|
||||||
|
echo "Removing stopped containers..."; \
|
||||||
|
docker rm $$CONTAINERS_STOPPED; \
|
||||||
|
else \
|
||||||
|
echo "Nothing to clean."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
.PHONY: vet
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
.PHONY: docs-image-push
|
||||||
|
docs-image:
|
||||||
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(DOCS_IMAGE)" ./docs
|
||||||
|
|||||||
@@ -1,40 +1,43 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./website/images/logo.svg" height="100" alt="Uncloud logo"/>
|
<img src="./website/images/logo.svg" height="100" width="100" alt="Uncloud logo"/>
|
||||||
<h1>Uncloud</h1>
|
<h1>Uncloud</h1>
|
||||||
<p><strong>Docker simplicity. Multi-machine power.</strong></p>
|
<p><strong>Docker simplicity. Multi-machine power.</strong></p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://docs.uncloud.run"><img src="https://img.shields.io/badge/Docs-blue.svg?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation"></a>
|
||||||
|
<a href="https://discord.gg/eR35KQJhPu"><img src="https://img.shields.io/badge/discord-5865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"></a>
|
||||||
|
<a href="https://x.com/psviderski"><img src="https://img.shields.io/badge/follow-black?style=for-the-badge&logo=X&logoColor=while" alt="Follow on X"></a>
|
||||||
|
<a href="https://github.com/sponsors/psviderski"><img src="https://img.shields.io/badge/Donate-EA4AAA.svg?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Donate"></a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Uncloud is a lightweight clustering and container orchestration tool that lets you deploy and manage web apps across
|
Uncloud is a lightweight clustering and container orchestration tool that lets you deploy and manage web apps across
|
||||||
cloud VMs and bare metal with minimized cluster management overhead. It creates a secure WireGuard mesh network between
|
cloud VMs and bare metal with minimised cluster management overhead. It creates a secure WireGuard mesh network between
|
||||||
your Docker hosts and provides automatic service discovery, load balancing, ingress with HTTPS, and simple CLI commands
|
your Docker hosts and provides automatic service discovery, load balancing, ingress with HTTPS, and simple CLI commands
|
||||||
to manage your apps.
|
to manage your apps.
|
||||||
|
|
||||||
Unlike traditional orchestrators, there's no central control plane and quorum to maintain. Each machine maintains a
|
Unlike traditional orchestrators, there's no central control plane and quorum to maintain. Each machine maintains a
|
||||||
synchronized copy of the cluster state through peer-to-peer communication, keeping cluster operations functional even if
|
synchronised copy of the cluster state through peer-to-peer communication, keeping cluster operations functional even if
|
||||||
some machines go offline.
|
some machines go offline.
|
||||||
|
|
||||||
Uncloud aims to be the solution for developers who want the flexibility of self-hosted infrastructure without the
|
Uncloud is the solution for developers who want the flexibility of self-hosted infrastructure without the operational
|
||||||
operational complexity of Kubernetes.
|
complexity of Kubernetes.
|
||||||
|
|
||||||
## 🎬 Quick demo
|
|
||||||
|
|
||||||
Deploy a highly available web app with automatic HTTPS across multiple regions and on-premises in just a couple minutes.
|
|
||||||
|
|
||||||
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
|
|
||||||
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment —
|
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment —
|
||||||
regardless of location or provider.
|
regardless of location or provider.
|
||||||
|
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
|
||||||
|
need to learn a new bespoke DSL.
|
||||||
|
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
|
||||||
|
soon.
|
||||||
|
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
||||||
|
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
|
||||||
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
|
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
|
||||||
IPs for direct cross-machine communication.
|
IPs for direct cross-machine communication.
|
||||||
* **No control plane**: Fully decentralized design eliminates single points of failure and reduces operational overhead.
|
* **No control plane**: Fully decentralised design eliminates single points of failure and reduces operational overhead.
|
||||||
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
||||||
model and troubleshooting.
|
model and troubleshooting.
|
||||||
* **Zero-downtime deployments**: Rolling updates with health checks and automatic rollback on failure.
|
|
||||||
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
|
||||||
* **Managed DNS**: Automatic DNS records `*.<id>.cluster.uncloud.run` for services with public access via managed
|
* **Managed DNS**: Automatic DNS records `*.<id>.cluster.uncloud.run` for services with public access via managed
|
||||||
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
||||||
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
||||||
@@ -42,17 +45,27 @@ Deploy a highly available web app with automatic HTTPS across multiple regions a
|
|||||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
||||||
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
||||||
|
|
||||||
Coming soon:
|
### 🚀 Coming soon
|
||||||
|
|
||||||
* Infrastructure as Code using Docker Compose format
|
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Push your Docker images directly to your
|
||||||
* Project isolation through environments/namespaces
|
machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
|
||||||
* Persistent volumes and secrets management
|
|
||||||
* Monitoring and log aggregation
|
|
||||||
* Database deployment and management
|
|
||||||
|
|
||||||
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
## 🎬 Quick demo
|
||||||
|
|
||||||

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

|
||||||
|
|
||||||
|
Here is a more advanced use case. Deploy a highly available web app with automatic HTTPS across multiple regions and
|
||||||
|
on-premises in just a couple minutes.
|
||||||
|
|
||||||
|
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
|
||||||
|
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## 💫 Why Uncloud?
|
## 💫 Why Uncloud?
|
||||||
|
|
||||||
@@ -62,14 +75,15 @@ believe there's a sweet spot in between — a pragmatic solution for the majorit
|
|||||||
scale. You should be able to:
|
scale. You should be able to:
|
||||||
|
|
||||||
* **Own your infrastructure and data**: Whether driven by costs, compliance, or flexibility, run applications on any
|
* **Own your infrastructure and data**: Whether driven by costs, compliance, or flexibility, run applications on any
|
||||||
combination of cloud VMs and personal hardware while maintaining the cloud-like experience you love.
|
combination of cloud VMs and personal hardware while controlling your data and maintaining the cloud-like experience
|
||||||
* **Stay simple**: Don't worry about control planes, highly-available clusters, or complex YAML configurations for
|
you love.
|
||||||
common use cases.
|
* **Stay simple as you grow**: Start with a single machine and add more whenever you need without changing your
|
||||||
|
workflow. No worrying about highly-available control planes or complex YAML configurations.
|
||||||
* **Build with proven primitives**: Get production-grade networking, deployment primitives, service discovery, load
|
* **Build with proven primitives**: Get production-grade networking, deployment primitives, service discovery, load
|
||||||
balancing, and ingress with HTTPS out of the box without becoming a distributed systems expert.
|
balancing, and ingress with HTTPS out of the box without becoming a distributed systems expert.
|
||||||
* **Support sustainable computing** 🌿: Minimize system overhead to maximize resources available for your applications.
|
* **Support sustainable computing** 🌿: Minimise system overhead to maximise resources available for your applications.
|
||||||
|
|
||||||
Uncloud's goal is to make deployment and management of containerized applications feel as seamless as using a cloud
|
Uncloud's goal is to make deployment and management of containerised applications feel as seamless as using a cloud
|
||||||
platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bare metal servers.
|
platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bare metal servers.
|
||||||
|
|
||||||
## 🚀 Quick start
|
## 🚀 Quick start
|
||||||
@@ -82,8 +96,10 @@ platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bar
|
|||||||
# or using curl (macOS/Linux)
|
# or using curl (macOS/Linux)
|
||||||
curl -fsS https://get.uncloud.run/install.sh | sh
|
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [Installation](https://docs.uncloud.run/getting-started/install-cli) for more options.
|
||||||
|
|
||||||
2. Initialize your first machine:
|
2. Initialise your first machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uc machine init root@your-server-ip
|
uc machine init root@your-server-ip
|
||||||
@@ -114,14 +130,20 @@ platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bar
|
|||||||
uncloud-uninstall
|
uncloud-uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
View the [user guide](docs/user_guide.md) for more information.
|
View the [Documentation](https://docs.uncloud.run) for more information.
|
||||||
|
|
||||||
## ⚙️ How it works
|
## ⚙️ How it works
|
||||||
|
|
||||||
Check out the [design document](docs/design.md) to understand Uncloud's design philosophy and goals. Here, let's peek
|
Check out the [design document](docs/design.md) to understand Uncloud's design philosophy and goals.
|
||||||
under the hood to see what happens when you run certain commands.
|
|
||||||
|
|
||||||
**When you initialize a new cluster on a machine:**
|
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Peek under the hood to see what happens when you run certain commands.</summary>
|
||||||
|
|
||||||
|
**When you initialise a new cluster on a machine:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uc machine init --name oracle-vm ubuntu@152.67.101.197
|
$ uc machine init --name oracle-vm ubuntu@152.67.101.197
|
||||||
@@ -254,23 +276,52 @@ Look ma, no control plane or master nodes to maintain! Just a simple overlay net
|
|||||||
sync that lets machines work together. Want to check on things or make changes? Connect to any machine either implicitly
|
sync that lets machines work together. Want to check on things or make changes? Connect to any machine either implicitly
|
||||||
using the CLI or directly over SSH. They all have the complete cluster state and can control everything. It's like each
|
using the CLI or directly over SSH. They all have the complete cluster state and can control everything. It's like each
|
||||||
machine is a full backup of your control plane.
|
machine is a full backup of your control plane.
|
||||||
|
</details>
|
||||||
|
|
||||||
## 🏗 Project status
|
## 🏗 Project status
|
||||||
|
|
||||||
Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
|
Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
|
||||||
and there may be breaking changes between releases.
|
and there may be breaking changes between releases.
|
||||||
|
|
||||||
I'd love your input! Here's how you can contribute:
|
We'd love your input! Here's how you can contribute:
|
||||||
|
|
||||||
* 🐛 Found a bug? [Open an issue](https://github.com/psviderski/uncloud/issues)
|
* 🐛 Found a bug? [Open an issue](https://github.com/psviderski/uncloud/issues)
|
||||||
* 💡 Have ideas? [Join the discussion](https://github.com/psviderski/uncloud/discussions)
|
* 💡 Have questions, ideas, or need help?
|
||||||
|
* Start a discussion or join an existing one in
|
||||||
|
the [Discussions](https://github.com/psviderski/uncloud/discussions).
|
||||||
|
* Join our [Discord community](https://discord.gg/eR35KQJhPu) where we discuss features, roadmap, implementation
|
||||||
|
details, and help each other out.
|
||||||
|
|
||||||
|
## 🙏 Inspiration & Acknowledgements
|
||||||
|
|
||||||
|
I'm grateful to the following projects that inspired Uncloud's design and implementation:
|
||||||
|
|
||||||
|
* [Kamal](https://kamal-deploy.org/) — for proving that even in the declarative era of Kubernetes there is a place for
|
||||||
|
simple deployment tools that use imperative commands without complex orchestration. Kamal powers the multi-billion
|
||||||
|
dollar company [37signals](https://37signals.com/) where it was created, and that's truly inspiring!
|
||||||
|
* [Fly.io](https://fly.io/) — for inspiring my vision for what self-hosted infrastructure should feel like, proving that
|
||||||
|
developer experience and powerful infrastructure can coexist beautifully.
|
||||||
|
* [Tailscale](https://tailscale.com/) — for pioneering the vision of decentralised flat mesh networking with an amazing
|
||||||
|
user experience that feels like magic.
|
||||||
|
* [Talos Linux](https://github.com/siderolabs/talos)
|
||||||
|
and [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/) — for the machine API design using
|
||||||
|
[grpc-proxy](https://github.com/siderolabs/grpc-proxy) and for its elegant approach to secure WireGuard-based overlay
|
||||||
|
networking with zero configuration.
|
||||||
|
* [Docker Swarm Classic](https://github.com/docker-archive/classicswarm) and
|
||||||
|
[Rancher 1.x](http://rancher-com-website-main-elb-elb-1798790864.us-west-2.elb.amazonaws.com/docs/rancher/v1.6/en/)
|
||||||
|
— for showing the power of simplicity and pragmatism in container orchestration and that not every problem needs the
|
||||||
|
complexity of Kubernetes.
|
||||||
|
|
||||||
|
Special thanks to the [Corrosion](https://github.com/superfly/corrosion) project by Fly.io for providing the distributed
|
||||||
|
SQLite database used to share Uncloud's cluster state.
|
||||||
|
|
||||||
## 📫 Stay updated
|
## 📫 Stay updated
|
||||||
|
|
||||||
* [Subscribe](https://uncloud.run/#subscribe) to my newsletter to follow the progress, get early insights into new
|
* Join our [Discord server](https://discord.gg/eR35KQJhPu) for real-time discussions, support, and updates.
|
||||||
|
* Follow [@psviderski](https://x.com/psviderski) on X/Twitter.
|
||||||
|
* Subscribe to [my newsletter](https://uncloud.run/#subscribe) to follow the progress, get early insights into new
|
||||||
features, and be the first to know when it's ready for production use.
|
features, and be the first to know when it's ready for production use.
|
||||||
* Watch this repository for releases.
|
* Watch this repository for releases.
|
||||||
* Follow [@psviderski](https://github.com/psviderski) on GitHub.
|
|
||||||
|
|
||||||
## ❤️ Contributors
|
## ❤️ Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewBuildCommand creates a new command to build services from a Compose file.
|
||||||
|
func NewBuildCommand() *cobra.Command {
|
||||||
|
opts := cli.BuildOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "build [FLAGS] [SERVICE...]",
|
||||||
|
Short: "Build services from a Compose file.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.Services = args
|
||||||
|
}
|
||||||
|
|
||||||
|
return runBuild(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.Files, "file", "f", nil,
|
||||||
|
"One or more Compose files to build (default compose.yaml)")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.Profiles, "profile", "p", nil,
|
||||||
|
"One or more Compose profiles to enable.")
|
||||||
|
cmd.Flags().BoolVarP(&opts.Push, "push", "P", false,
|
||||||
|
"Push built images to the registry after building. (default false)")
|
||||||
|
cmd.Flags().BoolVarP(&opts.NoCache, "no-cache", "n", false,
|
||||||
|
"Do not use cache when building images. (default false)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: deduplicate with a similar functino for deploy options
|
||||||
|
// projectOpts returns the project options for the Compose file(s).
|
||||||
|
func projectOptsFromBuildOpts(opts cli.BuildOptions) []composecli.ProjectOptionsFn {
|
||||||
|
projectOpts := []composecli.ProjectOptionsFn{}
|
||||||
|
|
||||||
|
if len(opts.Profiles) > 0 {
|
||||||
|
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.Profiles...))
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBuild parses the Compose file(s), builds the services, and pushes them if requested.
|
||||||
|
func runBuild(ctx context.Context, uncli *cli.CLI, opts cli.BuildOptions) error {
|
||||||
|
projectOpts := projectOptsFromBuildOpts(opts)
|
||||||
|
project, err := compose.LoadProject(ctx, opts.Files, projectOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.Services) > 0 {
|
||||||
|
project, err = project.WithSelectedServices(opts.Services)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("select services: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
||||||
|
|
||||||
|
if len(servicesToBuild) == 0 {
|
||||||
|
fmt.Println("No services to build.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.BuildServices(ctx, servicesToBuild, opts)
|
||||||
|
}
|
||||||
@@ -152,7 +152,8 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
|
func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
|
||||||
if _, err := clusterClient.GetDomain(ctx); err != nil {
|
domain, err := clusterClient.GetDomain(ctx)
|
||||||
|
if err != nil {
|
||||||
if errors.Is(err, api.ErrNotFound) {
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').")
|
fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').")
|
||||||
return nil
|
return nil
|
||||||
@@ -164,7 +165,7 @@ func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, prog
|
|||||||
// TODO: split the method into two: one to get the records and one to update them to ask for update confirmation.
|
// TODO: split the method into two: one to get the records and one to update them to ask for update confirmation.
|
||||||
|
|
||||||
var records []*pb.DNSRecord
|
var records []*pb.DNSRecord
|
||||||
err := progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
var err error
|
var err error
|
||||||
records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName)
|
records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName)
|
||||||
return err
|
return err
|
||||||
@@ -172,18 +173,19 @@ func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, prog
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, client.ErrNoReachableMachines) {
|
if errors.Is(err, client.ErrNoReachableMachines) {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("DNS records could not be updated as there are no internet-reachable machines running " +
|
fmt.Printf("DNS records for domain '%s' could not be updated as there are no internet-reachable "+
|
||||||
"caddy containers.")
|
"machines running caddy containers.\n", domain)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Possible solutions:")
|
fmt.Println("Possible solutions:")
|
||||||
fmt.Println("- Ensure your machines have public IP addresses")
|
fmt.Println("- Ensure your machines have public IP addresses")
|
||||||
fmt.Println("- Use --public-ip flag when adding machines to override the automatically detected IPs")
|
fmt.Println("- Use --public-ip flag when adding machines to override the automatically detected IPs")
|
||||||
fmt.Println("- Check firewall settings on your machines")
|
fmt.Println("- Check firewall settings on your machines")
|
||||||
fmt.Println("- Configure port forwarding if behind NAT")
|
fmt.Println("- Configure port forwarding if behind NAT")
|
||||||
fmt.Println("- Retry Caddy deployment after resolving connectivity issues with 'uc caddy deploy'")
|
fmt.Println("- Retry Caddy deployment with 'uc caddy deploy' after resolving connectivity issues")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Your services will not be accessible from the internet until at least one machine " +
|
fmt.Println("Your services won't be accessible from the internet until at least one machine " +
|
||||||
"becomes reachable.")
|
"becomes reachable. If you aren't planning to expose any services publicly, you can release " +
|
||||||
|
"the domain by running 'uc dns release'.")
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to update DNS records pointing to caddy service: %w", err)
|
return fmt.Errorf("failed to update DNS records pointing to caddy service: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,5 @@ func buildContextOptions(contexts []string, current string) []huh.Option[string]
|
|||||||
options[i] = opt
|
options[i] = opt
|
||||||
}
|
}
|
||||||
|
|
||||||
options[1].Selected(true)
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-2
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
@@ -17,7 +18,9 @@ import (
|
|||||||
|
|
||||||
type deployOptions struct {
|
type deployOptions struct {
|
||||||
files []string
|
files []string
|
||||||
|
profiles []string
|
||||||
services []string
|
services []string
|
||||||
|
noBuild bool
|
||||||
|
|
||||||
context string
|
context string
|
||||||
}
|
}
|
||||||
@@ -40,18 +43,36 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||||
"One or more Compose files to deploy services from. (default compose-ports-long.yaml)")
|
"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().BoolVarP(&opts.noBuild, "no-build", "n", false,
|
||||||
|
"Do not build images before deploying services. (default false)")
|
||||||
|
|
||||||
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
||||||
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// projectOpts returns the project options for the Compose file(s).
|
||||||
|
func projectOpts(opts deployOptions) []composecli.ProjectOptionsFn {
|
||||||
|
projectOpts := []composecli.ProjectOptionsFn{}
|
||||||
|
|
||||||
|
if len(opts.profiles) > 0 {
|
||||||
|
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.profiles...))
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectOpts
|
||||||
|
}
|
||||||
|
|
||||||
// runDeploy parses the Compose file(s) and deploys the services.
|
// runDeploy parses the Compose file(s) and deploys the services.
|
||||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
project, err := compose.LoadProject(ctx, opts.files)
|
projectOpts := projectOpts(opts)
|
||||||
|
|
||||||
|
project, err := compose.LoadProject(ctx, opts.files, projectOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load compose file(s): %w", err)
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
}
|
}
|
||||||
@@ -64,6 +85,23 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
||||||
|
|
||||||
|
if len(servicesToBuild) > 0 {
|
||||||
|
if opts.noBuild {
|
||||||
|
fmt.Println("Not building services as requested.")
|
||||||
|
} else {
|
||||||
|
buildOpts := cli.BuildOptions{
|
||||||
|
Push: true,
|
||||||
|
NoCache: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cli.BuildServices(ctx, servicesToBuild, buildOpts); err != nil {
|
||||||
|
return fmt.Errorf("build services: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
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)
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ func NewAddCommand() *cobra.Command {
|
|||||||
"blank '' or 'none' to disable ingress on this machine, or specify an IP address.",
|
"blank '' or 'none' to disable ingress on this machine, or specify an IP address.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "~/.ssh/id_ed25519",
|
||||||
"path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
"Path to SSH private key for remote login (if not already added to SSH agent).",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.version, "version", "latest",
|
&opts.version, "version", "latest",
|
||||||
@@ -92,7 +92,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
publicIP = &ip
|
publicIP = &ip
|
||||||
}
|
}
|
||||||
|
|
||||||
machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
||||||
Context: opts.context,
|
Context: opts.context,
|
||||||
MachineName: opts.name,
|
MachineName: opts.name,
|
||||||
PublicIP: publicIP,
|
PublicIP: publicIP,
|
||||||
@@ -102,6 +102,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
defer machineClient.Close()
|
defer machineClient.Close()
|
||||||
|
|
||||||
if opts.noCaddy {
|
if opts.noCaddy {
|
||||||
@@ -117,8 +118,11 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
|
|
||||||
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
||||||
// use the deployed image version. Otherwise, use the latest version.
|
// use the deployed image version. Otherwise, use the latest version.
|
||||||
|
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
|
||||||
|
// issues accessing the Machine API of existing machines in the cluster.
|
||||||
|
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
|
||||||
caddyImage := ""
|
caddyImage := ""
|
||||||
caddySvc, err := machineClient.InspectService(ctx, client.CaddyServiceName)
|
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, api.ErrNotFound) {
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
return fmt.Errorf("inspect caddy service: %w", err)
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
@@ -141,7 +145,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o
|
|||||||
|
|
||||||
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||||
// that may cause a small downtime.
|
// that may cause a small downtime.
|
||||||
d, err := machineClient.NewCaddyDeployment(caddyImage, api.Placement{})
|
d, err := clusterClient.NewCaddyDeployment(caddyImage, api.Placement{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ func NewInitCommand() *cobra.Command {
|
|||||||
"blank '' or 'none' to disable ingress on this machine, or specify an IP address.",
|
"blank '' or 'none' to disable ingress on this machine, or specify an IP address.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "~/.ssh/id_ed25519",
|
||||||
"Path to SSH private key for SSH remote login. (default ~/.ssh/id_*)",
|
"Path to SSH private key for remote login (if not already added to SSH agent).",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.version, "version", "latest",
|
&opts.version, "version", "latest",
|
||||||
@@ -114,7 +114,6 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
}
|
}
|
||||||
publicIP = &ip
|
publicIP = &ip
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{
|
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{
|
||||||
Context: opts.context,
|
Context: opts.context,
|
||||||
MachineName: opts.name,
|
MachineName: opts.name,
|
||||||
|
|||||||
@@ -14,18 +14,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
var clusterContext string
|
var contextName string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List machines in a cluster.",
|
Short: "List machines in a cluster.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return list(cmd.Context(), uncli, clusterContext)
|
return list(cmd.Context(), uncli, contextName)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&clusterContext, "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
|
||||||
@@ -68,7 +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, strings.Join(endpoints, ", "),
|
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||||
|
strings.Join(endpoints, ", "),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/lipgloss/tree"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type removeOptions struct {
|
||||||
|
force bool
|
||||||
|
yes bool
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRmCommand() *cobra.Command {
|
||||||
|
opts := removeOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rm MACHINE",
|
||||||
|
Aliases: []string{"remove", "delete"},
|
||||||
|
Short: "Remove a machine from a cluster.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return remove(cmd.Context(), uncli, args[0], opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
|
"Do not prompt for confirmation before removing the machine.")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(ctx context.Context, uncli *cli.CLI, machineName string, opts removeOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Verify the machine exists and list all service containers on it including stopped ones.
|
||||||
|
listCtx, machines, err := api.ProxyMachinesContext(ctx, client, []string{machineName})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(machines) == 0 {
|
||||||
|
return fmt.Errorf("machine '%s' not found in the cluster", machineName)
|
||||||
|
}
|
||||||
|
m := machines[0].Machine
|
||||||
|
|
||||||
|
listOpts := container.ListOptions{All: true}
|
||||||
|
machineContainers, err := client.Docker.ListServiceContainers(listCtx, "", listOpts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list containers: %w", err)
|
||||||
|
}
|
||||||
|
containers := machineContainers[0].Containers
|
||||||
|
|
||||||
|
if len(containers) > 0 {
|
||||||
|
plural := ""
|
||||||
|
if len(containers) > 1 {
|
||||||
|
plural = "s"
|
||||||
|
}
|
||||||
|
fmt.Printf("Found %d service container%s on machine '%s':\n", len(containers), plural, m.Name)
|
||||||
|
fmt.Println(formatContainerTree(containers))
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("This will remove all service containers on the machine, reset it to the uninitialised state, " +
|
||||||
|
"and remove it from the cluster.")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("No service containers found on machine '%s'.\n", m.Name)
|
||||||
|
fmt.Println("This will reset the machine to the uninitialised state and remove it from the cluster.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.yes {
|
||||||
|
confirmed, err := cli.Confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm removal: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. Machine was not removed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(containers) > 0 {
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
return removeContainers(ctx, client, containers)
|
||||||
|
}, uncli.ProgressOut(), "Removing containers")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove containers: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 4. Implement and call Reset via Machine API to reset the machine state to uninitialised.
|
||||||
|
// TODO: 5. Remove the machine from the cluster store.
|
||||||
|
|
||||||
|
return fmt.Errorf("resetting machine is not fully implemented yet")
|
||||||
|
//fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
|
||||||
|
//return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatContainerTree formats a list of containers grouped by service as a tree structure.
|
||||||
|
func formatContainerTree(containers []api.ServiceContainer) string {
|
||||||
|
if len(containers) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group containers by service.
|
||||||
|
serviceContainers := make(map[string][]api.ServiceContainer)
|
||||||
|
for _, ctr := range containers {
|
||||||
|
serviceName := ctr.ServiceName()
|
||||||
|
serviceContainers[serviceName] = append(serviceContainers[serviceName], ctr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tree output.
|
||||||
|
var output []string
|
||||||
|
serviceNames := slices.Sorted(maps.Keys(serviceContainers))
|
||||||
|
for _, serviceName := range serviceNames {
|
||||||
|
ctrs := serviceContainers[serviceName]
|
||||||
|
mode := ctrs[0].ServiceMode()
|
||||||
|
|
||||||
|
// Format a tree for the service with its containers.
|
||||||
|
plural := ""
|
||||||
|
if len(ctrs) > 1 {
|
||||||
|
plural = "s"
|
||||||
|
}
|
||||||
|
t := tree.Root(fmt.Sprintf("• %s (%s, %d container%s)", serviceName, mode, len(ctrs), plural)).
|
||||||
|
EnumeratorStyle(lipgloss.NewStyle().MarginLeft(2).MarginRight(1))
|
||||||
|
|
||||||
|
// Add containers as children.
|
||||||
|
for _, ctr := range ctrs {
|
||||||
|
state, _ := ctr.HumanState()
|
||||||
|
info := fmt.Sprintf("%s • %s • %s", ctr.Name, ctr.Config.Image, state)
|
||||||
|
t.Child(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
output = append(output, t.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(output, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeContainers removes the given service containers from the machine.
|
||||||
|
func removeContainers(ctx context.Context, client api.Client, containers []api.ServiceContainer) error {
|
||||||
|
if len(containers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
errCh := make(chan error)
|
||||||
|
|
||||||
|
for _, ctr := range containers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(c api.ServiceContainer) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Gracefully stop the container before removing it.
|
||||||
|
err := client.StopContainer(ctx, c.ServiceID(), c.ID, container.StopOptions{})
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
errCh <- fmt.Errorf("stop container '%s': %w", c.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.RemoveContainer(ctx, c.ServiceID(), c.ID, container.RemoveOptions{
|
||||||
|
// Remove anonymous volumes created by the container.
|
||||||
|
RemoveVolumes: true,
|
||||||
|
})
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
errCh <- fmt.Errorf("remove container '%s': %w", c.ID, err)
|
||||||
|
}
|
||||||
|
}(ctr)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
for e := range errCh {
|
||||||
|
err = errors.Join(err, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -6,13 +6,15 @@ import (
|
|||||||
|
|
||||||
func NewRootCommand() *cobra.Command {
|
func NewRootCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "machine",
|
Use: "machine",
|
||||||
Short: "Manage machines in an Uncloud cluster.",
|
Aliases: []string{"m"},
|
||||||
|
Short: "Manage machines in an Uncloud cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewAddCommand(),
|
NewAddCommand(),
|
||||||
NewInitCommand(),
|
NewInitCommand(),
|
||||||
NewListCommand(),
|
NewListCommand(),
|
||||||
|
NewRmCommand(),
|
||||||
NewTokenCommand(),
|
NewTokenCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"github.com/psviderski/uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
|
"github.com/psviderski/uncloud/internal/version"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ func main() {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uncloud",
|
Use: "uncloud",
|
||||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||||
|
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 {
|
||||||
@@ -73,6 +75,7 @@ func main() {
|
|||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
|
NewBuildCommand(),
|
||||||
caddy.NewRootCommand(),
|
caddy.NewRootCommand(),
|
||||||
cmdcontext.NewRootCommand(),
|
cmdcontext.NewRootCommand(),
|
||||||
dns.NewRootCommand(),
|
dns.NewRootCommand(),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type inspectOptions struct {
|
|||||||
func NewInspectCommand() *cobra.Command {
|
func NewInspectCommand() *cobra.Command {
|
||||||
opts := inspectOptions{}
|
opts := inspectOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "inspect",
|
Use: "inspect SERVICE",
|
||||||
Short: "Display detailed information on a service.",
|
Short: "Display detailed information on a service.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
|||||||
@@ -86,9 +86,10 @@ func NewRunCommand() *cobra.Command {
|
|||||||
"Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified\n"+
|
"Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified\n"+
|
||||||
"and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+
|
"and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+
|
||||||
"Examples:\n"+
|
"Examples:\n"+
|
||||||
" -p 8080/https Publish port 8080 as HTTPS via load balancer with default service-name.cluster-domain hostname\n"+
|
" -p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname\n"+
|
||||||
" -p app.example.com:8080/https Publish port 8080 as HTTPS via load balancer with custom hostname\n"+
|
" -p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname\n"+
|
||||||
" -p 9000:8080 Publish port 8080 as TCP port 9000 via load balancer\n"+
|
// TODO: add support for publishing L4 tcp/udp ports.
|
||||||
|
//" -p 9000:8080 Publish port 8080 as TCP port 9000 via reverse proxy\n"+
|
||||||
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
|
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
|
||||||
cmd.Flags().StringVar(&opts.pull, "pull", api.PullPolicyMissing,
|
cmd.Flags().StringVar(&opts.pull, "pull", api.PullPolicyMissing,
|
||||||
fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').",
|
fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').",
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func confirm() (bool, error) {
|
|||||||
Negative("No").
|
Negative("No").
|
||||||
Value(&confirmed),
|
Value(&confirmed),
|
||||||
),
|
),
|
||||||
)
|
).WithAccessible(true)
|
||||||
if err := form.Run(); err != nil {
|
if err := form.Run(); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func NewCreateCommand() *cobra.Command {
|
|||||||
opts := createOptions{}
|
opts := createOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create [FLAGS] VOLUME_NAME",
|
Use: "create VOLUME_NAME",
|
||||||
Short: "Create a volume on a specific machine.",
|
Short: "Create a volume on a specific machine.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func NewInspectCommand() *cobra.Command {
|
|||||||
opts := inspectOptions{}
|
opts := inspectOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "inspect [FLAGS] VOLUME_NAME",
|
Use: "inspect VOLUME_NAME",
|
||||||
Short: "Display detailed information on a volume.",
|
Short: "Display detailed information on a volume.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func NewListCommand() *cobra.Command {
|
|||||||
opts := listOptions{}
|
opts := listOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls [FLAGS]",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List volumes across all machines in the cluster.",
|
Short: "List volumes across all machines in the cluster.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func NewRemoveCommand() *cobra.Command {
|
|||||||
opts := removeOptions{}
|
opts := removeOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm [FLAGS] VOLUME_NAME [VOLUME_NAME...]",
|
Use: "rm VOLUME_NAME [VOLUME_NAME...]",
|
||||||
Aliases: []string{"remove", "delete"},
|
Aliases: []string{"remove", "delete"},
|
||||||
Short: "Remove one or more volumes.",
|
Short: "Remove one or more volumes.",
|
||||||
Long: "Remove one or more volumes. You cannot remove a volume that is in use by a container.",
|
Long: "Remove one or more volumes. You cannot remove a volume that is in use by a container.",
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/daemon"
|
"github.com/psviderski/uncloud/internal/daemon"
|
||||||
"github.com/psviderski/uncloud/internal/log"
|
"github.com/psviderski/uncloud/internal/log"
|
||||||
"github.com/psviderski/uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/psviderski/uncloud/internal/version"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -22,6 +24,7 @@ func main() {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uncloudd",
|
Use: "uncloudd",
|
||||||
Short: "Uncloud machine daemon.",
|
Short: "Uncloud machine daemon.",
|
||||||
|
Version: version.String(),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -52,5 +55,4 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
cobra.CheckErr(cmd.ExecuteContext(ctx))
|
cobra.CheckErr(cmd.ExecuteContext(ctx))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Ignore everything and use an allowlist of what is allowed to not package any secrets by accident.
|
||||||
|
*
|
||||||
|
|
||||||
|
# Allow files and directories.
|
||||||
|
!docs/
|
||||||
|
!src/
|
||||||
|
!static/
|
||||||
|
!pkg/
|
||||||
|
!Caddyfile
|
||||||
|
!*.js
|
||||||
|
!*.json
|
||||||
|
|
||||||
|
# Ignore unnecessary files inside allowed directories.
|
||||||
|
**/.DS_Store
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.docusaurus
|
||||||
|
.cache-loader
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
|
:8000 {
|
||||||
|
root * /usr/share/caddy
|
||||||
|
file_server
|
||||||
|
try_files {path} /index.html
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Stage 1: Base image.
|
||||||
|
## Start with a base image containing NodeJS so we can build Docusaurus.
|
||||||
|
FROM node:lts-alpine AS base
|
||||||
|
## Disable colour output from yarn to make logs easier to read.
|
||||||
|
ENV FORCE_COLOR=0
|
||||||
|
## Enable corepack.
|
||||||
|
RUN corepack enable
|
||||||
|
## Set the working directory to `/opt/docusaurus`.
|
||||||
|
WORKDIR /opt/docusaurus
|
||||||
|
|
||||||
|
# Stage 2: Production build.
|
||||||
|
FROM base AS prod
|
||||||
|
## Set the working directory to `/opt/docusaurus`.
|
||||||
|
WORKDIR /opt/docusaurus
|
||||||
|
## Copy over the source code.
|
||||||
|
COPY . /opt/docusaurus/
|
||||||
|
## Install dependencies with `--immutable` to ensure reproducibility.
|
||||||
|
RUN npm ci
|
||||||
|
## Build the static site.
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 3: Serve static site with Caddy.
|
||||||
|
FROM caddy:2.10.0-alpine AS caddy
|
||||||
|
## Copy the Caddyfile.
|
||||||
|
COPY ./Caddyfile /etc/caddy/Caddyfile
|
||||||
|
## Copy the Docusaurus build output.
|
||||||
|
COPY --from=prod /opt/docusaurus/build /usr/share/caddy
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Website
|
||||||
|
|
||||||
|
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
Using SSH:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ USE_SSH=true yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Not using SSH:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
# WireGuard overlay network for Docker containers
|
||||||
|
|
||||||
|
# How to connect Docker containers across multiple hosts using WireGuard
|
||||||
|
|
||||||
|
# Connect Docker containers across multiple hosts with WireGuard
|
||||||
|
|
||||||
|
You have Docker containers running on different Linux machines. You want container A on one machine to talk directly to
|
||||||
|
container B on another machine using their private IPs. For example, to run your application and database containers on
|
||||||
|
separate machines without exposing them publicly. Here's how you can use pure WireGuard and some networking tricks to
|
||||||
|
make this work.
|
||||||
|
|
||||||
|
I implemented this technique to enable cross-machine container communication in
|
||||||
|
[Uncloud](https://github.com/psviderski/uncloud), an open source clustering and deployment tool for Docker.
|
||||||
|
|
||||||
|
* [What we're building](#what-were-building)
|
||||||
|
* [Prequisites](#prerequisites)
|
||||||
|
* [Step 1: Configure Docker networks](#step-1-configure-docker-networks)
|
||||||
|
* [Step 2: Connect Docker networks with WireGuard](#step-2-connect-docker-networks-with-wireguard)
|
||||||
|
* [Step 3: Configure IP routing](#step-3-configure-ip-routing)
|
||||||
|
* [Step 4: Testing](#step-4-testing)
|
||||||
|
* [Step 5: Make the configuration persistent](#step-5-make-the-configuration-persistent)
|
||||||
|
* [Scaling beyond two machines and limitations](#scaling-beyond-two-machines-and-limitations)
|
||||||
|
* [Automating with Uncloud](#automating-with-uncloud)
|
||||||
|
* [Alternative solutions](#alternative-solutions)
|
||||||
|
* [Conclusion](#conclusion)
|
||||||
|
|
||||||
|
## What we're building
|
||||||
|
|
||||||
|
Docker containers are typically connected to a [bridge network](https://docs.docker.com/engine/network/drivers/bridge/)
|
||||||
|
on their host machine, which allows them to communicate with each other. A bridge network also provides isolation from
|
||||||
|
containers not connected to it and other networks on the host. What we want to achieve is to connect these bridge
|
||||||
|
networks across machines so that containers on different machines can communicate as if they were connected to the same
|
||||||
|
local bridge network.
|
||||||
|
|
||||||
|
The incantation we need is called a site-to-site VPN. Any solution would work. Moreover, if the machines are on the same
|
||||||
|
local network, they're already connected and only miss the appropriate routing configuration. But I'll describe a more
|
||||||
|
versatile approach that works even when the machines are on different continents or behind NAT. WireGuard is the ideal
|
||||||
|
solution for this use case: it's lightweight, [fast](https://www.wireguard.com/performance/), simple to configure,
|
||||||
|
provides [strong security](https://www.wireguard.com/protocol/) and NAT traversal.
|
||||||
|
|
||||||
|
We'll create a new Docker bridge network `multi-host` on each machine with unique subnets. Then establish a secure
|
||||||
|
WireGuard tunnel between the machines and configure IP routing so that `multi-host` bridge networks become routable via
|
||||||
|
the tunnel. Finally, we'll run containers on each machine connected to the `multi-host` network and test that they can
|
||||||
|
communicate with each other using their private IPs.
|
||||||
|
|
||||||
|
I will use these two machines:
|
||||||
|
|
||||||
|
* Machine 1: Debian 12 virtual machine in my homelab network in Australia which is behind NAT
|
||||||
|
* Machine 2: Ubuntu 24.04 server from Hetzner in Finland that has a public IP
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
You now have:
|
||||||
|
|
||||||
|
- Your **own Excalidraw instance** running on your server
|
||||||
|
- A **public URL** with **automatic HTTPS** you can share with your team and friends
|
||||||
|
- **Full control over your data** — no analytics or tracking
|
||||||
|
|
||||||
|
## Convert to Docker Compose format
|
||||||
|
|
||||||
|
Uncloud supports the [Compose file format](https://docs.docker.com/reference/compose-file/) for defining services. This
|
||||||
|
allows you to version control your deployments, share configurations with your team, and deploy complex multi-service
|
||||||
|
applications with a single command.
|
||||||
|
|
||||||
|
Let's create a `compose.yaml` file in your current directory for the `excalidraw` service we just deployed.
|
||||||
|
|
||||||
|
```yaml title="compose.yaml"
|
||||||
|
services:
|
||||||
|
excalidraw:
|
||||||
|
image: excalidraw/excalidraw
|
||||||
|
x-ports:
|
||||||
|
- 80/https
|
||||||
|
```
|
||||||
|
|
||||||
|
:::info note
|
||||||
|
|
||||||
|
The `x-ports` key is an Uncloud-specific extension to the Compose file format. It allows you to specify ports that
|
||||||
|
should be published as HTTP(S) endpoints. Uncloud automatically configures the reverse proxy (Caddy) to route traffic to
|
||||||
|
these ports.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
Now deploy it:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Services are up to date.
|
||||||
|
```
|
||||||
|
|
||||||
|
Since `excalidraw` service is already running with the same configuration, Uncloud recognises there's nothing to change.
|
||||||
|
We've successfully converted our deployment created with `uc run` to a Compose file.
|
||||||
|
|
||||||
|
## Use your own domain
|
||||||
|
|
||||||
|
Want to use your own domain, for example, `excalidraw.example.com` instead of `excalidraw.7za6s7.cluster.uncloud.run`?
|
||||||
|
|
||||||
|
Add a CNAME record `excalidraw.example.com` in your DNS provider (Cloudflare, Namecheap, etc.) pointing to
|
||||||
|
`excalidraw.7za6s7.cluster.uncloud.run`. Alternatively, you can add an A record pointing to your server's IP.
|
||||||
|
|
||||||
|
Then update the published port `80/https` in `compose.yaml` to use your domain:
|
||||||
|
|
||||||
|
```yaml title="compose.yaml"
|
||||||
|
...
|
||||||
|
x-ports:
|
||||||
|
- excalidraw.example.com:80/https
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, deploy the changes:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uc deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Deployment plan:
|
||||||
|
- Deploy service [name=excalidraw]
|
||||||
|
- machine-dc3c: Run container [image=excalidraw/excalidraw]
|
||||||
|
- machine-dc3c: Remove container [name=excalidraw-azpc]
|
||||||
|
|
||||||
|
Do you want to continue?
|
||||||
|
|
||||||
|
Choose [y/N]: y
|
||||||
|
Chose: Yes!
|
||||||
|
|
||||||
|
[+] Deploying services 2/2
|
||||||
|
✔ Container excalidraw-0z12 on machine-dc3c Started 3.5s
|
||||||
|
✔ Container excalidraw-azpc on machine-dc3c Removed 3.4s
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice how Uncloud performed a **zero-downtime deployment** — it started the new container with the updated
|
||||||
|
configuration before removing the old one. Your service stayed available throughout the update.
|
||||||
|
|
||||||
|
Give it a moment for Caddy to obtain a TLS certificate, then visit https://excalidraw.example.com.
|
||||||
|
|
||||||
|
## Clean up
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
TBD
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
label: Getting started
|
||||||
|
collapsed: false # keep the category open by default
|
||||||
|
link:
|
||||||
|
type: generated-index
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 389 KiB |
@@ -0,0 +1,153 @@
|
|||||||
|
// @ts-check
|
||||||
|
// `@type` JSDoc annotations allow editor autocompletion and type checking
|
||||||
|
// (when paired with `@ts-check`).
|
||||||
|
// There are various equivalent ways to declare your Docusaurus config.
|
||||||
|
// See: https://docusaurus.io/docs/api/docusaurus-config
|
||||||
|
|
||||||
|
import {themes as prismThemes} from 'prism-react-renderer';
|
||||||
|
|
||||||
|
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||||
|
|
||||||
|
/** @type {import('@docusaurus/types').Config} */
|
||||||
|
const config = {
|
||||||
|
title: 'Uncloud Docs',
|
||||||
|
tagline: 'Dinosaurs are cool',
|
||||||
|
favicon: 'img/favicon.png',
|
||||||
|
|
||||||
|
// Set the production url of your site here
|
||||||
|
url: 'https://docs.uncloud.run',
|
||||||
|
// Set the /<baseUrl>/ pathname under which your site is served
|
||||||
|
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||||
|
baseUrl: '/',
|
||||||
|
|
||||||
|
// GitHub pages deployment config.
|
||||||
|
// If you aren't using GitHub pages, you don't need these.
|
||||||
|
organizationName: '', // Usually your GitHub org/user name.
|
||||||
|
projectName: '', // Usually your repo name.
|
||||||
|
|
||||||
|
onBrokenLinks: 'throw',
|
||||||
|
onBrokenMarkdownLinks: 'warn',
|
||||||
|
|
||||||
|
// Even if you don't use internationalization, you can use this field to set
|
||||||
|
// useful metadata like html lang. For example, if your site is Chinese, you
|
||||||
|
// may want to replace "en" with "zh-Hans".
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: 'en',
|
||||||
|
locales: ['en'],
|
||||||
|
},
|
||||||
|
|
||||||
|
themes: [
|
||||||
|
[
|
||||||
|
'@easyops-cn/docusaurus-search-local',
|
||||||
|
/** @type {import("@easyops-cn/docusaurus-search-local").PluginOptions} */
|
||||||
|
({
|
||||||
|
docsRouteBasePath: '/',
|
||||||
|
hashed: true,
|
||||||
|
highlightSearchTermsOnTargetPage: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
presets: [
|
||||||
|
[
|
||||||
|
'classic',
|
||||||
|
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||||
|
({
|
||||||
|
docs: {
|
||||||
|
// Remove this to remove the "edit this page" links.
|
||||||
|
editUrl: 'https://github.com/psviderski/uncloud/edit/main/docs/',
|
||||||
|
// Serve the docs at the site's root.
|
||||||
|
routeBasePath: '/',
|
||||||
|
showLastUpdateTime: true,
|
||||||
|
sidebarPath: './sidebars.js',
|
||||||
|
},
|
||||||
|
// blog: {
|
||||||
|
// showReadingTime: true,
|
||||||
|
// feedOptions: {
|
||||||
|
// type: ['rss', 'atom'],
|
||||||
|
// xslt: true,
|
||||||
|
// },
|
||||||
|
// // Please change this to your repo.
|
||||||
|
// // Remove this to remove the "edit this page" links.
|
||||||
|
// editUrl:
|
||||||
|
// 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||||
|
// // Useful options to enforce blogging best practices
|
||||||
|
// onInlineTags: 'warn',
|
||||||
|
// onInlineAuthors: 'warn',
|
||||||
|
// onUntruncatedBlogPosts: 'warn',
|
||||||
|
// },
|
||||||
|
theme: {
|
||||||
|
customCss: './src/css/custom.css',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
themeConfig:
|
||||||
|
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||||
|
({
|
||||||
|
colorMode: {
|
||||||
|
// The color mode when user first visits the site.
|
||||||
|
defaultMode: "light",
|
||||||
|
// Whether to use the prefers-color-scheme media-query, using user system preferences,
|
||||||
|
// instead of the hardcoded defaultMode.
|
||||||
|
respectPrefersColorScheme: true,
|
||||||
|
},
|
||||||
|
// The meta image URL for the site (og:image and twitter:image meta tags).
|
||||||
|
// Relative to your site's "static" directory. Cannot be SVGs. Can be external URLs too.
|
||||||
|
image: 'img/social-card.png',
|
||||||
|
navbar: {
|
||||||
|
title: 'Uncloud Docs',
|
||||||
|
logo: {
|
||||||
|
alt: 'Uncloud Logo',
|
||||||
|
src: 'img/logo.svg',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: 'search',
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://github.com/psviderski/uncloud',
|
||||||
|
label: 'GitHub',
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
style: 'light',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Uncloud',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
html: 'Made with ❤️ by <a href="https://github.com/psviderski">@psviderski</a> in Australia'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Community',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Discord',
|
||||||
|
href: 'https://discord.gg/eR35KQJhPu',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'X',
|
||||||
|
href: 'https://x.com/psviderski',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GitHub',
|
||||||
|
href: 'https://github.com/psviderski/uncloud',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
prism: {
|
||||||
|
theme: prismThemes.palenight,
|
||||||
|
//darkTheme: prismThemes.dracula,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Generated
+18434
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"docusaurus": "docusaurus",
|
||||||
|
"start": "docusaurus start",
|
||||||
|
"build": "docusaurus build",
|
||||||
|
"swizzle": "docusaurus swizzle",
|
||||||
|
"deploy": "docusaurus deploy",
|
||||||
|
"clear": "docusaurus clear",
|
||||||
|
"serve": "docusaurus serve",
|
||||||
|
"write-translations": "docusaurus write-translations",
|
||||||
|
"write-heading-ids": "docusaurus write-heading-ids"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@docusaurus/core": "3.7.0",
|
||||||
|
"@docusaurus/preset-classic": "3.7.0",
|
||||||
|
"@easyops-cn/docusaurus-search-local": "^0.49.2",
|
||||||
|
"@mdx-js/react": "^3.0.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"prism-react-renderer": "^2.3.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@docusaurus/module-type-aliases": "3.7.0",
|
||||||
|
"@docusaurus/types": "3.7.0"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.5%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 3 chrome version",
|
||||||
|
"last 3 firefox version",
|
||||||
|
"last 5 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creating a sidebar enables you to:
|
||||||
|
- create an ordered group of docs
|
||||||
|
- render a sidebar for each doc of that group
|
||||||
|
- provide next/previous navigation
|
||||||
|
|
||||||
|
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||||
|
|
||||||
|
Create as many sidebars as you want.
|
||||||
|
|
||||||
|
@type {import('@docusaurus/plugin-content-docs').SidebarsConfig}
|
||||||
|
*/
|
||||||
|
const sidebars = {
|
||||||
|
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||||
|
sidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||||
|
|
||||||
|
// But you can create a sidebar manually
|
||||||
|
/*
|
||||||
|
tutorialSidebar: [
|
||||||
|
'intro',
|
||||||
|
'hello',
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
label: 'Tutorial',
|
||||||
|
items: ['tutorial-basics/create-a-document'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sidebars;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Any CSS included here will be global. The classic template
|
||||||
|
* bundles Infima by default. Infima is a CSS framework designed to
|
||||||
|
* work well for content-centric websites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;700&display=swap');
|
||||||
|
|
||||||
|
/* You can override the default Infima variables here. */
|
||||||
|
:root {
|
||||||
|
--ifm-font-family-base: 'Inter', sans-serif;
|
||||||
|
--ifm-heading-font-family: 'Inter', sans-serif;
|
||||||
|
|
||||||
|
--ifm-color-primary: #006be6;
|
||||||
|
--ifm-color-primary-dark: #0060cf;
|
||||||
|
--ifm-color-primary-darker: #005bc4;
|
||||||
|
--ifm-color-primary-darkest: #004ba1;
|
||||||
|
--ifm-color-primary-light: #0076fd;
|
||||||
|
--ifm-color-primary-lighter: #0a7cff;
|
||||||
|
--ifm-color-primary-lightest: #2c8eff;
|
||||||
|
--ifm-code-font-size: 95%;
|
||||||
|
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--ifm-color-primary: #3391ee;
|
||||||
|
--ifm-color-primary-dark: #1883ec;
|
||||||
|
--ifm-color-primary-darker: #137be3;
|
||||||
|
--ifm-color-primary-darkest: #1066bb;
|
||||||
|
--ifm-color-primary-light: #4e9ff0;
|
||||||
|
--ifm-color-primary-lighter: #5ba7f1;
|
||||||
|
--ifm-color-primary-lightest: #83bcf5;
|
||||||
|
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: var(--ifm-font-weight-normal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
// Import the original mapper
|
||||||
|
import MDXComponents from '@theme-original/MDXComponents';
|
||||||
|
import Tabs from '@theme/Tabs';
|
||||||
|
import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Re-use the default mapping
|
||||||
|
...MDXComponents,
|
||||||
|
// Global import the <Tabs> and <TabItem> components
|
||||||
|
Tabs,
|
||||||
|
TabItem,
|
||||||
|
};
|
||||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
|
||||||
|
<g>
|
||||||
|
<rect x="0" y="0" width="80" height="80" rx="10" fill="#18181B"/>
|
||||||
|
<path d="M20 32 L20 48 L36 48 L36 32 M60 32 L45 32 L45 48 L60 48" stroke="white" stroke-width="6" fill="none"
|
||||||
|
stroke-linecap="square" stroke-linejoin="miter"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -2,8 +2,6 @@ module github.com/psviderski/uncloud
|
|||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.23.2
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.4.0
|
github.com/BurntSushi/toml v1.4.0
|
||||||
github.com/Masterminds/semver v1.5.0
|
github.com/Masterminds/semver v1.5.0
|
||||||
@@ -36,6 +34,8 @@ require (
|
|||||||
github.com/ipfs/go-log/v2 v2.5.1
|
github.com/ipfs/go-log/v2 v2.5.1
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/lmittmann/tint v1.0.5
|
github.com/lmittmann/tint v1.0.5
|
||||||
|
github.com/miekg/dns v1.1.65
|
||||||
|
github.com/moby/term v0.5.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.0
|
||||||
github.com/siderolabs/discovery-api v0.1.4
|
github.com/siderolabs/discovery-api v0.1.4
|
||||||
@@ -46,9 +46,9 @@ require (
|
|||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.0
|
||||||
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.32.0
|
golang.org/x/crypto v0.33.0
|
||||||
golang.org/x/net v0.34.0
|
golang.org/x/net v0.35.0
|
||||||
golang.org/x/sync v0.10.0
|
golang.org/x/sync v0.11.0
|
||||||
golang.org/x/sys v0.31.0
|
golang.org/x/sys v0.31.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
|
||||||
@@ -197,7 +197,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/dns v1.1.62 // indirect
|
|
||||||
github.com/miekg/pkcs11 v1.1.1 // 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
|
||||||
@@ -213,7 +212,8 @@ require (
|
|||||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||||
github.com/moby/sys/signal v0.7.1 // indirect
|
github.com/moby/sys/signal v0.7.1 // indirect
|
||||||
github.com/moby/term v0.5.0 // indirect
|
github.com/moby/sys/user v0.3.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
|
||||||
@@ -303,11 +303,11 @@ require (
|
|||||||
go.uber.org/zap/exp v0.2.0 // indirect
|
go.uber.org/zap/exp v0.2.0 // indirect
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
|
golang.org/x/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-20241215155358-4a5509556b9e // indirect
|
||||||
golang.org/x/mod v0.22.0 // indirect
|
golang.org/x/mod v0.23.0 // indirect
|
||||||
golang.org/x/term v0.28.0 // indirect
|
golang.org/x/term v0.29.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
golang.org/x/time v0.8.0 // indirect
|
golang.org/x/time v0.8.0 // indirect
|
||||||
golang.org/x/tools v0.29.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 v0.0.0-20240401170217-c3f982113cda // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
|
|||||||
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
@@ -153,6 +155,8 @@ github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA
|
|||||||
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
|
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
|
||||||
github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY=
|
github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY=
|
||||||
github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8=
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
|
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
|
||||||
@@ -721,8 +725,8 @@ github.com/mholt/acmez/v2 v2.0.3 h1:CgDBlEwg3QBp6s45tPQmFIBrkRIkBT4rW4orMM6p4sw=
|
|||||||
github.com/mholt/acmez/v2 v2.0.3/go.mod h1:pQ1ysaDeGrIMvJ9dfJMk5kJNkn7L2sb3UhyrX6Q91cw=
|
github.com/mholt/acmez/v2 v2.0.3/go.mod h1:pQ1ysaDeGrIMvJ9dfJMk5kJNkn7L2sb3UhyrX6Q91cw=
|
||||||
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
|
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
|
||||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||||
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
|
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
|
||||||
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
|
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
|
||||||
github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
||||||
github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
|
github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
|
||||||
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
||||||
@@ -1196,8 +1200,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
|
|||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 h1:TgSqweA595vD0Zt86JzLv3Pb/syKg8gd5KMGGbJPYFw=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 h1:TgSqweA595vD0Zt86JzLv3Pb/syKg8gd5KMGGbJPYFw=
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
@@ -1214,8 +1218,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -1237,8 +1241,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
@@ -1252,8 +1256,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -1310,8 +1314,8 @@ golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
|||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -1322,8 +1326,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -1345,8 +1349,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
|
|||||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
||||||
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/docker/cli/cli/config"
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/archive"
|
||||||
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
|
"github.com/docker/docker/registry"
|
||||||
|
"github.com/moby/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BuildOptions struct {
|
||||||
|
Files []string
|
||||||
|
Profiles []string
|
||||||
|
Services []string
|
||||||
|
Push bool
|
||||||
|
NoCache bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServicesThatNeedBuild returns a map of services that require building
|
||||||
|
func GetServicesThatNeedBuild(project *composetypes.Project) map[string]composetypes.ServiceConfig {
|
||||||
|
servicesToBuild := make(map[string]composetypes.ServiceConfig, len(project.Services))
|
||||||
|
for serviceName, service := range project.Services {
|
||||||
|
if service.Build == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
servicesToBuild[serviceName] = service
|
||||||
|
}
|
||||||
|
return servicesToBuild
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildServices builds the services defined in the provided map.
|
||||||
|
func BuildServices(ctx context.Context, servicesToBuild map[string]composetypes.ServiceConfig, opts BuildOptions) error {
|
||||||
|
fmt.Println("Building services...")
|
||||||
|
|
||||||
|
// Init docker client (can be local or remote, depending on DOCKER_HOST environment variable)
|
||||||
|
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dockerCli.Close()
|
||||||
|
|
||||||
|
serviceImages := make(map[string]string, len(servicesToBuild))
|
||||||
|
|
||||||
|
// Build the services using the local docker client and compose libraries
|
||||||
|
for _, service := range servicesToBuild {
|
||||||
|
fmt.Printf("Building service: %s\n", service.Name)
|
||||||
|
imageName, err := buildSingleService(ctx, dockerCli, service, opts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build service %s: %w", service.Name, err)
|
||||||
|
}
|
||||||
|
serviceImages[service.Name] = imageName
|
||||||
|
}
|
||||||
|
fmt.Printf("Service images are built.\n")
|
||||||
|
|
||||||
|
if opts.Push {
|
||||||
|
err = pushServiceImages(ctx, dockerCli, serviceImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSingleService builds a single service using the Docker client and Compose libraries.
|
||||||
|
func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, service composetypes.ServiceConfig, opts BuildOptions) (string, error) {
|
||||||
|
if service.Build == nil {
|
||||||
|
return "", fmt.Errorf("service %s has no build configuration", service.Name)
|
||||||
|
}
|
||||||
|
if service.Image == "" {
|
||||||
|
return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet", service.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildContextPath := service.Build.Context
|
||||||
|
imageName := service.Image
|
||||||
|
|
||||||
|
// Create a tar archive of the build context
|
||||||
|
buildContext, err := archive.TarWithOptions(buildContextPath, &archive.TarOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create build context for service %s: %w", service.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildOptions := types.ImageBuildOptions{
|
||||||
|
// TODO: Support Dockerfiles outside the build context
|
||||||
|
// See https://github.com/docker/compose/blob/cf89fd1aa1328d5af77658ccc5a1e1b29981ae80/pkg/compose/build_classic.go#L92
|
||||||
|
Dockerfile: service.Build.Dockerfile,
|
||||||
|
Tags: []string{imageName},
|
||||||
|
Remove: true, // Remove intermediate containers
|
||||||
|
NoCache: opts.NoCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
buildResponse, err := dockerCli.ImageBuild(ctx, buildContext, buildOptions)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to build image for service %s: %w", service.Name, err)
|
||||||
|
}
|
||||||
|
defer buildResponse.Body.Close()
|
||||||
|
|
||||||
|
// Display the build response
|
||||||
|
fd, isTerminal := term.GetFdInfo(os.Stdout)
|
||||||
|
if err := jsonmessage.DisplayJSONMessagesStream(buildResponse.Body, os.Stdout, fd, isTerminal, nil); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to display build response for service %s: %w", service.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushSingleServiceImage pushes a single service image.
|
||||||
|
func pushSingleServiceImage(ctx context.Context, dockerCli *dockerclient.Client, serviceName string, imageName string) error {
|
||||||
|
ref, err := reference.ParseNormalizedNamed(imageName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
repoInfo, err := registry.ParseRepositoryInfo(ref)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
registryKey := repoInfo.Index.Name
|
||||||
|
if repoInfo.Index.Official {
|
||||||
|
registryKey = registry.IndexServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the Docker config file with auth details, if available
|
||||||
|
configFile := config.LoadDefaultConfigFile(os.Stderr)
|
||||||
|
|
||||||
|
authConfig, err := configFile.GetAuthConfig(registryKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
authJSON, err := json.Marshal(authConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal auth config for registry %s: %w", registryKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authStr := base64.URLEncoding.EncodeToString(authJSON)
|
||||||
|
|
||||||
|
pushOptions := image.PushOptions{
|
||||||
|
RegistryAuth: authStr,
|
||||||
|
}
|
||||||
|
pushResponse, err := dockerCli.ImagePush(ctx, imageName, pushOptions)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to push image %s: %w", imageName, err)
|
||||||
|
}
|
||||||
|
defer pushResponse.Close()
|
||||||
|
|
||||||
|
fmt.Printf("Pushing image %s for service %s...\n", imageName, serviceName)
|
||||||
|
|
||||||
|
fd, isTerminal := term.GetFdInfo(os.Stdout)
|
||||||
|
if err := jsonmessage.DisplayJSONMessagesStream(pushResponse, os.Stdout, fd, isTerminal, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to display push response for image %s: %w", imageName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Image %s pushed successfully.\n", imageName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushServiceImages pushes all built service images to the registry.
|
||||||
|
func pushServiceImages(ctx context.Context, dockerCli *dockerclient.Client, serviceImages map[string]string) error {
|
||||||
|
fmt.Printf("Pushing images...\n")
|
||||||
|
for serviceName, imageName := range serviceImages {
|
||||||
|
if err := pushSingleServiceImage(ctx, dockerCli, serviceName, imageName); err != nil {
|
||||||
|
return fmt.Errorf("push image for service %s: %w", serviceName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+47
-18
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/docker/cli/cli/streams"
|
"github.com/docker/cli/cli/streams"
|
||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"github.com/psviderski/uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
@@ -16,8 +17,8 @@ import (
|
|||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/psviderski/uncloud/pkg/client/connector"
|
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
"github.com/charmbracelet/huh"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -194,6 +195,17 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check machine meets all necessary system requirements before proceeding.
|
||||||
|
checkResp, err := machineClient.CheckPrerequisites(ctx, &emptypb.Empty{})
|
||||||
|
// TODO(lhf): remove Unimplemented check when v0.9.0 is released.
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() != codes.Unimplemented {
|
||||||
|
return nil, fmt.Errorf("check machine prerequisites: %w", err)
|
||||||
|
}
|
||||||
|
} else if !checkResp.Satisfied {
|
||||||
|
return nil, fmt.Errorf("machine prerequisites not satisfied: %s", checkResp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
req := &pb.InitClusterRequest{
|
req := &pb.InitClusterRequest{
|
||||||
MachineName: opts.MachineName,
|
MachineName: opts.MachineName,
|
||||||
Network: pb.NewIPPrefix(opts.Network),
|
Network: pb.NewIPPrefix(opts.Network),
|
||||||
@@ -241,22 +253,28 @@ type AddMachineOptions struct {
|
|||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddMachine provisions a remote machine and adds it to the cluster. It returns a client to interact with the machine
|
// AddMachine provisions a remote machine and adds it to the cluster. It returns a cluster client and a machine client.
|
||||||
// which should be closed after use by the caller.
|
// The cluster client is connected to the existing machine in the cluster. It was used to add the new machine to the
|
||||||
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, error) {
|
// cluster. The machine client is connected to the new machine and can be used to interact with it.
|
||||||
|
// Both client should be closed after use by the caller.
|
||||||
|
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) {
|
||||||
contextName := opts.Context
|
contextName := opts.Context
|
||||||
if contextName == "" {
|
if contextName == "" {
|
||||||
contextName = cli.Config.CurrentContext
|
contextName = cli.Config.CurrentContext
|
||||||
}
|
}
|
||||||
c, err := cli.ConnectCluster(ctx, contextName)
|
c, err := cli.ConnectCluster(ctx, contextName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||||
}
|
}
|
||||||
defer c.Close()
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
machineClient, err := cli.provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
machineClient, err := cli.provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -267,21 +285,32 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
||||||
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("inspect machine: %w", err)
|
return nil, nil, fmt.Errorf("inspect machine: %w", err)
|
||||||
}
|
}
|
||||||
if minfo.Id != "" {
|
if minfo.Id != "" {
|
||||||
if err = cli.promptResetMachine(); err != nil {
|
if err = cli.promptResetMachine(); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check machine meets all necessary system requirements before proceeding.
|
||||||
|
checkResp, err := machineClient.CheckPrerequisites(ctx, &emptypb.Empty{})
|
||||||
|
// TODO(lhf): remove Unimplemented check when v0.9.0 is released.
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() != codes.Unimplemented {
|
||||||
|
return nil, nil, fmt.Errorf("check machine prerequisites: %w", err)
|
||||||
|
}
|
||||||
|
} else if !checkResp.Satisfied {
|
||||||
|
return nil, nil, fmt.Errorf("machine prerequisites not satisfied: %s", checkResp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
tokenResp, err := machineClient.Token(ctx, &emptypb.Empty{})
|
tokenResp, err := machineClient.Token(ctx, &emptypb.Empty{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get remote machine token: %w", err)
|
return nil, nil, fmt.Errorf("get remote machine token: %w", err)
|
||||||
}
|
}
|
||||||
token, err := machine.ParseToken(tokenResp.Token)
|
token, err := machine.ParseToken(tokenResp.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse remote machine token: %w", err)
|
return nil, nil, fmt.Errorf("parse remote machine token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the machine in the cluster using its public key and endpoints from the token.
|
// Register the machine in the cluster using its public key and endpoints from the token.
|
||||||
@@ -307,13 +336,13 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
|
|
||||||
addResp, err := c.AddMachine(ctx, addReq)
|
addResp, err := c.AddMachine(ctx, addReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err)
|
return nil, nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List other machines in the cluster to include them in the join request.
|
// List other machines in the cluster to include them in the join request.
|
||||||
machines, err := c.ListMachines(ctx, nil)
|
machines, err := c.ListMachines(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list cluster machines: %w", err)
|
return nil, nil, fmt.Errorf("list cluster machines: %w", err)
|
||||||
}
|
}
|
||||||
otherMachines := make([]*pb.MachineInfo, 0, len(machines)-1)
|
otherMachines := make([]*pb.MachineInfo, 0, len(machines)-1)
|
||||||
for _, m := range machines {
|
for _, m := range machines {
|
||||||
@@ -328,7 +357,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
OtherMachines: otherMachines,
|
OtherMachines: otherMachines,
|
||||||
}
|
}
|
||||||
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
|
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
|
||||||
return nil, fmt.Errorf("join cluster: %w", err)
|
return nil, nil, fmt.Errorf("join cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: fix empty context name when using the current context (contextName == "").
|
// TODO: fix empty context name when using the current context (contextName == "").
|
||||||
@@ -344,10 +373,10 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
}
|
}
|
||||||
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||||
if err = cli.Config.Save(); err != nil {
|
if err = cli.Config.Save(); err != nil {
|
||||||
return nil, fmt.Errorf("save config: %w", err)
|
return nil, nil, fmt.Errorf("save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return machineClient, nil
|
return c, machineClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and returns
|
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and returns
|
||||||
@@ -403,7 +432,7 @@ func (cli *CLI) promptResetMachine() error {
|
|||||||
Negative("No").
|
Negative("No").
|
||||||
Value(&confirm),
|
Value(&confirm),
|
||||||
),
|
),
|
||||||
)
|
).WithAccessible(true)
|
||||||
if err := form.Run(); err != nil {
|
if err := form.Run(); err != nil {
|
||||||
return fmt.Errorf("prompt user to confirm: %w", err)
|
return fmt.Errorf("prompt user to confirm: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func Confirm() (bool, error) {
|
|||||||
Negative("No").
|
Negative("No").
|
||||||
Value(&confirmed),
|
Value(&confirmed),
|
||||||
),
|
),
|
||||||
)
|
).WithAccessible(true)
|
||||||
if err := form.Run(); err != nil {
|
if err := form.Run(); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -45,11 +45,19 @@ func (h *SlogTextHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *SlogTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
func (h *SlogTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
return h.TextHandler.WithAttrs(attrs)
|
return &SlogTextHandler{
|
||||||
|
TextHandler: h.TextHandler.WithAttrs(attrs).(*slog.TextHandler),
|
||||||
|
opts: h.opts,
|
||||||
|
w: h.w,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SlogTextHandler) WithGroup(name string) slog.Handler {
|
func (h *SlogTextHandler) WithGroup(name string) slog.Handler {
|
||||||
return h.TextHandler.WithGroup(name)
|
return &SlogTextHandler{
|
||||||
|
TextHandler: h.TextHandler.WithGroup(name).(*slog.TextHandler),
|
||||||
|
opts: h.opts,
|
||||||
|
w: h.w,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SlogTextHandler) Handle(ctx context.Context, r slog.Record) error {
|
func (h *SlogTextHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||||
|
|||||||
@@ -163,6 +163,63 @@ func (x *NetworkConfig) GetPublicKey() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CheckPrerequisitesResponse struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
// Overall status of the checks.
|
||||||
|
Satisfied bool `protobuf:"varint,1,opt,name=satisfied,proto3" json:"satisfied,omitempty"`
|
||||||
|
// Error message if not satisfied (empty if all checks pass).
|
||||||
|
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CheckPrerequisitesResponse) Reset() {
|
||||||
|
*x = CheckPrerequisitesResponse{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CheckPrerequisitesResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CheckPrerequisitesResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *CheckPrerequisitesResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[2]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CheckPrerequisitesResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*CheckPrerequisitesResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CheckPrerequisitesResponse) GetSatisfied() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Satisfied
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CheckPrerequisitesResponse) GetError() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Error
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type InitClusterRequest struct {
|
type InitClusterRequest struct {
|
||||||
state protoimpl.MessageState
|
state protoimpl.MessageState
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -180,7 +237,7 @@ type InitClusterRequest struct {
|
|||||||
func (x *InitClusterRequest) Reset() {
|
func (x *InitClusterRequest) Reset() {
|
||||||
*x = InitClusterRequest{}
|
*x = InitClusterRequest{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[2]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[3]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -193,7 +250,7 @@ func (x *InitClusterRequest) String() string {
|
|||||||
func (*InitClusterRequest) ProtoMessage() {}
|
func (*InitClusterRequest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *InitClusterRequest) ProtoReflect() protoreflect.Message {
|
func (x *InitClusterRequest) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[2]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[3]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -206,7 +263,7 @@ func (x *InitClusterRequest) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use InitClusterRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use InitClusterRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*InitClusterRequest) Descriptor() ([]byte, []int) {
|
func (*InitClusterRequest) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{2}
|
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{3}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *InitClusterRequest) GetMachineName() string {
|
func (x *InitClusterRequest) GetMachineName() string {
|
||||||
@@ -271,7 +328,7 @@ type InitClusterResponse struct {
|
|||||||
func (x *InitClusterResponse) Reset() {
|
func (x *InitClusterResponse) Reset() {
|
||||||
*x = InitClusterResponse{}
|
*x = InitClusterResponse{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[3]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[4]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -284,7 +341,7 @@ func (x *InitClusterResponse) String() string {
|
|||||||
func (*InitClusterResponse) ProtoMessage() {}
|
func (*InitClusterResponse) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *InitClusterResponse) ProtoReflect() protoreflect.Message {
|
func (x *InitClusterResponse) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[3]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[4]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -297,7 +354,7 @@ func (x *InitClusterResponse) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use InitClusterResponse.ProtoReflect.Descriptor instead.
|
// Deprecated: Use InitClusterResponse.ProtoReflect.Descriptor instead.
|
||||||
func (*InitClusterResponse) Descriptor() ([]byte, []int) {
|
func (*InitClusterResponse) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{3}
|
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{4}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *InitClusterResponse) GetMachine() *MachineInfo {
|
func (x *InitClusterResponse) GetMachine() *MachineInfo {
|
||||||
@@ -319,7 +376,7 @@ type JoinClusterRequest struct {
|
|||||||
func (x *JoinClusterRequest) Reset() {
|
func (x *JoinClusterRequest) Reset() {
|
||||||
*x = JoinClusterRequest{}
|
*x = JoinClusterRequest{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[4]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[5]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -332,7 +389,7 @@ func (x *JoinClusterRequest) String() string {
|
|||||||
func (*JoinClusterRequest) ProtoMessage() {}
|
func (*JoinClusterRequest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *JoinClusterRequest) ProtoReflect() protoreflect.Message {
|
func (x *JoinClusterRequest) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[4]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[5]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -345,7 +402,7 @@ func (x *JoinClusterRequest) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use JoinClusterRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use JoinClusterRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*JoinClusterRequest) Descriptor() ([]byte, []int) {
|
func (*JoinClusterRequest) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{4}
|
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{5}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *JoinClusterRequest) GetMachine() *MachineInfo {
|
func (x *JoinClusterRequest) GetMachine() *MachineInfo {
|
||||||
@@ -373,7 +430,7 @@ type TokenResponse struct {
|
|||||||
func (x *TokenResponse) Reset() {
|
func (x *TokenResponse) Reset() {
|
||||||
*x = TokenResponse{}
|
*x = TokenResponse{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[5]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -386,7 +443,7 @@ func (x *TokenResponse) String() string {
|
|||||||
func (*TokenResponse) ProtoMessage() {}
|
func (*TokenResponse) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *TokenResponse) ProtoReflect() protoreflect.Message {
|
func (x *TokenResponse) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[5]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -399,7 +456,7 @@ func (x *TokenResponse) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use TokenResponse.ProtoReflect.Descriptor instead.
|
// Deprecated: Use TokenResponse.ProtoReflect.Descriptor instead.
|
||||||
func (*TokenResponse) Descriptor() ([]byte, []int) {
|
func (*TokenResponse) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{5}
|
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{6}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *TokenResponse) GetToken() string {
|
func (x *TokenResponse) GetToken() string {
|
||||||
@@ -409,6 +466,44 @@ func (x *TokenResponse) GetToken() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResetRequest struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ResetRequest) Reset() {
|
||||||
|
*x = ResetRequest{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ResetRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ResetRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ResetRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ResetRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ResetRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{7}
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
state protoimpl.MessageState
|
state protoimpl.MessageState
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -423,7 +518,7 @@ type Service struct {
|
|||||||
func (x *Service) Reset() {
|
func (x *Service) Reset() {
|
||||||
*x = Service{}
|
*x = Service{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -436,7 +531,7 @@ func (x *Service) String() string {
|
|||||||
func (*Service) ProtoMessage() {}
|
func (*Service) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Service) ProtoReflect() protoreflect.Message {
|
func (x *Service) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -449,7 +544,7 @@ func (x *Service) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Service.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Service.ProtoReflect.Descriptor instead.
|
||||||
func (*Service) Descriptor() ([]byte, []int) {
|
func (*Service) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{6}
|
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{8}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Service) GetId() string {
|
func (x *Service) GetId() string {
|
||||||
@@ -491,7 +586,7 @@ type InspectServiceRequest struct {
|
|||||||
func (x *InspectServiceRequest) Reset() {
|
func (x *InspectServiceRequest) Reset() {
|
||||||
*x = InspectServiceRequest{}
|
*x = InspectServiceRequest{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -504,7 +599,7 @@ func (x *InspectServiceRequest) String() string {
|
|||||||
func (*InspectServiceRequest) ProtoMessage() {}
|
func (*InspectServiceRequest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *InspectServiceRequest) ProtoReflect() protoreflect.Message {
|
func (x *InspectServiceRequest) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -517,7 +612,7 @@ func (x *InspectServiceRequest) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use InspectServiceRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use InspectServiceRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*InspectServiceRequest) Descriptor() ([]byte, []int) {
|
func (*InspectServiceRequest) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{7}
|
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{9}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *InspectServiceRequest) GetId() string {
|
func (x *InspectServiceRequest) GetId() string {
|
||||||
@@ -538,7 +633,7 @@ type InspectServiceResponse struct {
|
|||||||
func (x *InspectServiceResponse) Reset() {
|
func (x *InspectServiceResponse) Reset() {
|
||||||
*x = InspectServiceResponse{}
|
*x = InspectServiceResponse{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[10]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -551,7 +646,7 @@ func (x *InspectServiceResponse) String() string {
|
|||||||
func (*InspectServiceResponse) ProtoMessage() {}
|
func (*InspectServiceResponse) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *InspectServiceResponse) ProtoReflect() protoreflect.Message {
|
func (x *InspectServiceResponse) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[10]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -564,7 +659,7 @@ func (x *InspectServiceResponse) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use InspectServiceResponse.ProtoReflect.Descriptor instead.
|
// Deprecated: Use InspectServiceResponse.ProtoReflect.Descriptor instead.
|
||||||
func (*InspectServiceResponse) Descriptor() ([]byte, []int) {
|
func (*InspectServiceResponse) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{8}
|
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{10}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *InspectServiceResponse) GetService() *Service {
|
func (x *InspectServiceResponse) GetService() *Service {
|
||||||
@@ -587,7 +682,7 @@ type Service_Container struct {
|
|||||||
func (x *Service_Container) Reset() {
|
func (x *Service_Container) Reset() {
|
||||||
*x = Service_Container{}
|
*x = Service_Container{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[11]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -600,7 +695,7 @@ func (x *Service_Container) String() string {
|
|||||||
func (*Service_Container) ProtoMessage() {}
|
func (*Service_Container) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Service_Container) ProtoReflect() protoreflect.Message {
|
func (x *Service_Container) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
|
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[11]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -613,7 +708,7 @@ func (x *Service_Container) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Service_Container.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Service_Container.ProtoReflect.Descriptor instead.
|
||||||
func (*Service_Container) Descriptor() ([]byte, []int) {
|
func (*Service_Container) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{6, 0}
|
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{8, 0}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Service_Container) GetMachineId() string {
|
func (x *Service_Container) GetMachineId() string {
|
||||||
@@ -659,77 +754,91 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
|
|||||||
0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x09,
|
0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x09,
|
||||||
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62,
|
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62,
|
||||||
0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70,
|
0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70,
|
||||||
0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0xc3, 0x01, 0x0a, 0x12, 0x49, 0x6e, 0x69,
|
0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0x50, 0x0a, 0x1a, 0x43, 0x68, 0x65, 0x63,
|
||||||
0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65,
|
||||||
0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01,
|
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66,
|
||||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d,
|
0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73,
|
||||||
0x65, 0x12, 0x27, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01,
|
0x66, 0x69, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20,
|
||||||
0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69,
|
0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xc3, 0x01, 0x0a, 0x12, 0x49,
|
||||||
0x78, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x26, 0x0a, 0x09, 0x70, 0x75,
|
0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||||
0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e,
|
0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d, 0x65,
|
||||||
0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x48, 0x00, 0x52, 0x08, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63,
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e,
|
||||||
0x49, 0x70, 0x12, 0x26, 0x0a, 0x0e, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x5f,
|
0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02,
|
||||||
0x61, 0x75, 0x74, 0x6f, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x0c, 0x70, 0x75,
|
0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x72, 0x65,
|
||||||
0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x42, 0x12, 0x0a, 0x10, 0x70, 0x75,
|
0x66, 0x69, 0x78, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x26, 0x0a, 0x09,
|
||||||
0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x41,
|
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||||
0x0a, 0x13, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73,
|
0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x48, 0x00, 0x52, 0x08, 0x70, 0x75, 0x62, 0x6c,
|
||||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65,
|
0x69, 0x63, 0x49, 0x70, 0x12, 0x26, 0x0a, 0x0e, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69,
|
||||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63,
|
0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x0c,
|
||||||
0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e,
|
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x42, 0x12, 0x0a, 0x10,
|
||||||
0x65, 0x22, 0x79, 0x0a, 0x12, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
|
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67,
|
||||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69,
|
0x22, 0x41, 0x0a, 0x13, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52,
|
||||||
|
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69,
|
||||||
0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d,
|
0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d,
|
||||||
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68,
|
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68,
|
||||||
0x69, 0x6e, 0x65, 0x12, 0x37, 0x0a, 0x0e, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x5f, 0x6d, 0x61, 0x63,
|
0x69, 0x6e, 0x65, 0x22, 0x79, 0x0a, 0x12, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74,
|
||||||
0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70,
|
0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63,
|
||||||
0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0d, 0x6f,
|
0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69,
|
||||||
0x74, 0x68, 0x65, 0x72, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x22, 0x25, 0x0a, 0x0d,
|
0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61,
|
||||||
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a,
|
0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x37, 0x0a, 0x0e, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x5f, 0x6d,
|
||||||
0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f,
|
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e,
|
||||||
0x6b, 0x65, 0x6e, 0x22, 0xc3, 0x01, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12,
|
0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52,
|
||||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12,
|
0x0d, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x22, 0x25,
|
||||||
0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
|
0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||||
0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
|
0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
|
||||||
0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x36, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61,
|
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x0e, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x65, 0x74, 0x52, 0x65,
|
||||||
0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x70,
|
0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xc3, 0x01, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||||
0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69,
|
0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,
|
||||||
0x6e, 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x1a,
|
0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
0x48, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1d, 0x0a, 0x0a,
|
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20,
|
||||||
0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x36, 0x0a, 0x0a, 0x63, 0x6f, 0x6e,
|
||||||
0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x63,
|
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e,
|
||||||
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09,
|
0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x6e, 0x74,
|
||||||
0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x22, 0x27, 0x0a, 0x15, 0x49, 0x6e, 0x73,
|
0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
|
||||||
0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
|
0x73, 0x1a, 0x48, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1d,
|
||||||
0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,
|
0x0a, 0x0a, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||||
0x69, 0x64, 0x22, 0x40, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72,
|
0x28, 0x09, 0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x12, 0x1c, 0x0a,
|
||||||
0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26, 0x0a, 0x07,
|
0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c,
|
||||||
0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e,
|
0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x22, 0x27, 0x0a, 0x15, 0x49,
|
||||||
0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x07, 0x73, 0x65, 0x72,
|
0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71,
|
||||||
0x76, 0x69, 0x63, 0x65, 0x32, 0xc0, 0x02, 0x0a, 0x07, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65,
|
0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||||
0x12, 0x40, 0x0a, 0x0b, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12,
|
0x52, 0x02, 0x69, 0x64, 0x22, 0x40, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53,
|
||||||
0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65,
|
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26,
|
||||||
0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49,
|
0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||||
0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
0x0c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x07, 0x73,
|
||||||
0x73, 0x65, 0x12, 0x3e, 0x0a, 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65,
|
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x32, 0xc3, 0x03, 0x0a, 0x07, 0x4d, 0x61, 0x63, 0x68, 0x69,
|
||||||
0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73,
|
0x6e, 0x65, 0x12, 0x4d, 0x0a, 0x12, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65,
|
||||||
0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f,
|
0x71, 0x75, 0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||||
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70,
|
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
|
||||||
0x74, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f,
|
0x1a, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72,
|
||||||
|
0x65, 0x71, 0x75, 0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||||
|
0x65, 0x12, 0x40, 0x0a, 0x0b, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
|
||||||
|
0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74,
|
||||||
|
0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||||
|
0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||||
|
0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74,
|
||||||
|
0x65, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75,
|
||||||
|
0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f,
|
||||||
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d,
|
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d,
|
||||||
0x70, 0x74, 0x79, 0x1a, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52,
|
0x70, 0x74, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x16, 0x2e, 0x67,
|
||||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x33, 0x0a, 0x07, 0x49, 0x6e, 0x73, 0x70, 0x65,
|
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45,
|
||||||
0x63, 0x74, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
0x6d, 0x70, 0x74, 0x79, 0x1a, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
|
||||||
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69,
|
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x33, 0x0a, 0x07, 0x49, 0x6e, 0x73, 0x70,
|
||||||
0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x49, 0x0a, 0x0e,
|
0x65, 0x63, 0x74, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
|
||||||
0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x1a,
|
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x10, 0x2e, 0x61, 0x70,
|
||||||
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76,
|
0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x32, 0x0a,
|
||||||
0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69,
|
0x05, 0x52, 0x65, 0x73, 0x65, 0x74, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x73,
|
||||||
0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
|
0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
|
||||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75,
|
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74,
|
||||||
0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69,
|
0x79, 0x12, 0x49, 0x0a, 0x0e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76,
|
||||||
0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61,
|
0x69, 0x63, 0x65, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63,
|
||||||
0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62,
|
0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||||
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72,
|
||||||
|
0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35,
|
||||||
|
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64,
|
||||||
|
0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e,
|
||||||
|
0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61,
|
||||||
|
0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -744,48 +853,54 @@ func file_internal_machine_api_pb_machine_proto_rawDescGZIP() []byte {
|
|||||||
return file_internal_machine_api_pb_machine_proto_rawDescData
|
return file_internal_machine_api_pb_machine_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_internal_machine_api_pb_machine_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
var file_internal_machine_api_pb_machine_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
|
||||||
var file_internal_machine_api_pb_machine_proto_goTypes = []any{
|
var file_internal_machine_api_pb_machine_proto_goTypes = []any{
|
||||||
(*MachineInfo)(nil), // 0: api.MachineInfo
|
(*MachineInfo)(nil), // 0: api.MachineInfo
|
||||||
(*NetworkConfig)(nil), // 1: api.NetworkConfig
|
(*NetworkConfig)(nil), // 1: api.NetworkConfig
|
||||||
(*InitClusterRequest)(nil), // 2: api.InitClusterRequest
|
(*CheckPrerequisitesResponse)(nil), // 2: api.CheckPrerequisitesResponse
|
||||||
(*InitClusterResponse)(nil), // 3: api.InitClusterResponse
|
(*InitClusterRequest)(nil), // 3: api.InitClusterRequest
|
||||||
(*JoinClusterRequest)(nil), // 4: api.JoinClusterRequest
|
(*InitClusterResponse)(nil), // 4: api.InitClusterResponse
|
||||||
(*TokenResponse)(nil), // 5: api.TokenResponse
|
(*JoinClusterRequest)(nil), // 5: api.JoinClusterRequest
|
||||||
(*Service)(nil), // 6: api.Service
|
(*TokenResponse)(nil), // 6: api.TokenResponse
|
||||||
(*InspectServiceRequest)(nil), // 7: api.InspectServiceRequest
|
(*ResetRequest)(nil), // 7: api.ResetRequest
|
||||||
(*InspectServiceResponse)(nil), // 8: api.InspectServiceResponse
|
(*Service)(nil), // 8: api.Service
|
||||||
(*Service_Container)(nil), // 9: api.Service.Container
|
(*InspectServiceRequest)(nil), // 9: api.InspectServiceRequest
|
||||||
(*IP)(nil), // 10: api.IP
|
(*InspectServiceResponse)(nil), // 10: api.InspectServiceResponse
|
||||||
(*IPPrefix)(nil), // 11: api.IPPrefix
|
(*Service_Container)(nil), // 11: api.Service.Container
|
||||||
(*IPPort)(nil), // 12: api.IPPort
|
(*IP)(nil), // 12: api.IP
|
||||||
(*emptypb.Empty)(nil), // 13: google.protobuf.Empty
|
(*IPPrefix)(nil), // 13: api.IPPrefix
|
||||||
|
(*IPPort)(nil), // 14: api.IPPort
|
||||||
|
(*emptypb.Empty)(nil), // 15: google.protobuf.Empty
|
||||||
}
|
}
|
||||||
var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
|
var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
|
||||||
1, // 0: api.MachineInfo.network:type_name -> api.NetworkConfig
|
1, // 0: api.MachineInfo.network:type_name -> api.NetworkConfig
|
||||||
10, // 1: api.MachineInfo.public_ip:type_name -> api.IP
|
12, // 1: api.MachineInfo.public_ip:type_name -> api.IP
|
||||||
11, // 2: api.NetworkConfig.subnet:type_name -> api.IPPrefix
|
13, // 2: api.NetworkConfig.subnet:type_name -> api.IPPrefix
|
||||||
10, // 3: api.NetworkConfig.management_ip:type_name -> api.IP
|
12, // 3: api.NetworkConfig.management_ip:type_name -> api.IP
|
||||||
12, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort
|
14, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort
|
||||||
11, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix
|
13, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix
|
||||||
10, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP
|
12, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP
|
||||||
0, // 7: api.InitClusterResponse.machine:type_name -> api.MachineInfo
|
0, // 7: api.InitClusterResponse.machine:type_name -> api.MachineInfo
|
||||||
0, // 8: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
|
0, // 8: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
|
||||||
0, // 9: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
|
0, // 9: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
|
||||||
9, // 10: api.Service.containers:type_name -> api.Service.Container
|
11, // 10: api.Service.containers:type_name -> api.Service.Container
|
||||||
6, // 11: api.InspectServiceResponse.service:type_name -> api.Service
|
8, // 11: api.InspectServiceResponse.service:type_name -> api.Service
|
||||||
2, // 12: api.Machine.InitCluster:input_type -> api.InitClusterRequest
|
15, // 12: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
|
||||||
4, // 13: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest
|
3, // 13: api.Machine.InitCluster:input_type -> api.InitClusterRequest
|
||||||
13, // 14: api.Machine.Token:input_type -> google.protobuf.Empty
|
5, // 14: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest
|
||||||
13, // 15: api.Machine.Inspect:input_type -> google.protobuf.Empty
|
15, // 15: api.Machine.Token:input_type -> google.protobuf.Empty
|
||||||
7, // 16: api.Machine.InspectService:input_type -> api.InspectServiceRequest
|
15, // 16: api.Machine.Inspect:input_type -> google.protobuf.Empty
|
||||||
3, // 17: api.Machine.InitCluster:output_type -> api.InitClusterResponse
|
7, // 17: api.Machine.Reset:input_type -> api.ResetRequest
|
||||||
13, // 18: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
|
9, // 18: api.Machine.InspectService:input_type -> api.InspectServiceRequest
|
||||||
5, // 19: api.Machine.Token:output_type -> api.TokenResponse
|
2, // 19: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
|
||||||
0, // 20: api.Machine.Inspect:output_type -> api.MachineInfo
|
4, // 20: api.Machine.InitCluster:output_type -> api.InitClusterResponse
|
||||||
8, // 21: api.Machine.InspectService:output_type -> api.InspectServiceResponse
|
15, // 21: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
|
||||||
17, // [17:22] is the sub-list for method output_type
|
6, // 22: api.Machine.Token:output_type -> api.TokenResponse
|
||||||
12, // [12:17] is the sub-list for method input_type
|
0, // 23: api.Machine.Inspect:output_type -> api.MachineInfo
|
||||||
|
15, // 24: api.Machine.Reset:output_type -> google.protobuf.Empty
|
||||||
|
10, // 25: api.Machine.InspectService:output_type -> api.InspectServiceResponse
|
||||||
|
19, // [19:26] is the sub-list for method output_type
|
||||||
|
12, // [12:19] is the sub-list for method input_type
|
||||||
12, // [12:12] is the sub-list for extension type_name
|
12, // [12:12] is the sub-list for extension type_name
|
||||||
12, // [12:12] is the sub-list for extension extendee
|
12, // [12:12] is the sub-list for extension extendee
|
||||||
0, // [0:12] is the sub-list for field type_name
|
0, // [0:12] is the sub-list for field type_name
|
||||||
@@ -823,7 +938,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_machine_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_machine_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*InitClusterRequest); i {
|
switch v := v.(*CheckPrerequisitesResponse); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
case 1:
|
case 1:
|
||||||
@@ -835,7 +950,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_machine_proto_msgTypes[3].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_machine_proto_msgTypes[3].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*InitClusterResponse); i {
|
switch v := v.(*InitClusterRequest); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
case 1:
|
case 1:
|
||||||
@@ -847,7 +962,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_machine_proto_msgTypes[4].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_machine_proto_msgTypes[4].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*JoinClusterRequest); i {
|
switch v := v.(*InitClusterResponse); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
case 1:
|
case 1:
|
||||||
@@ -859,7 +974,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_machine_proto_msgTypes[5].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_machine_proto_msgTypes[5].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*TokenResponse); i {
|
switch v := v.(*JoinClusterRequest); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
case 1:
|
case 1:
|
||||||
@@ -871,7 +986,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_machine_proto_msgTypes[6].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_machine_proto_msgTypes[6].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*Service); i {
|
switch v := v.(*TokenResponse); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
case 1:
|
case 1:
|
||||||
@@ -883,7 +998,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_machine_proto_msgTypes[7].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_machine_proto_msgTypes[7].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*InspectServiceRequest); i {
|
switch v := v.(*ResetRequest); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
case 1:
|
case 1:
|
||||||
@@ -895,7 +1010,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_machine_proto_msgTypes[8].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_machine_proto_msgTypes[8].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*InspectServiceResponse); i {
|
switch v := v.(*Service); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
case 1:
|
case 1:
|
||||||
@@ -907,6 +1022,30 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_machine_proto_msgTypes[9].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_machine_proto_msgTypes[9].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*InspectServiceRequest); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_internal_machine_api_pb_machine_proto_msgTypes[10].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*InspectServiceResponse); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_internal_machine_api_pb_machine_proto_msgTypes[11].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*Service_Container); i {
|
switch v := v.(*Service_Container); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
@@ -919,7 +1058,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_machine_proto_msgTypes[2].OneofWrappers = []any{
|
file_internal_machine_api_pb_machine_proto_msgTypes[3].OneofWrappers = []any{
|
||||||
(*InitClusterRequest_PublicIp)(nil),
|
(*InitClusterRequest_PublicIp)(nil),
|
||||||
(*InitClusterRequest_PublicIpAuto)(nil),
|
(*InitClusterRequest_PublicIpAuto)(nil),
|
||||||
}
|
}
|
||||||
@@ -929,7 +1068,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: file_internal_machine_api_pb_machine_proto_rawDesc,
|
RawDescriptor: file_internal_machine_api_pb_machine_proto_rawDesc,
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 10,
|
NumMessages: 12,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ import "google/protobuf/empty.proto";
|
|||||||
import "internal/machine/api/pb/common.proto";
|
import "internal/machine/api/pb/common.proto";
|
||||||
|
|
||||||
service Machine {
|
service Machine {
|
||||||
|
// CheckPrerequisites verifies if the machine meets all necessary system requirements to participate in the cluster.
|
||||||
|
rpc CheckPrerequisites(google.protobuf.Empty) returns (CheckPrerequisitesResponse);
|
||||||
rpc InitCluster(InitClusterRequest) returns (InitClusterResponse);
|
rpc InitCluster(InitClusterRequest) returns (InitClusterResponse);
|
||||||
rpc JoinCluster(JoinClusterRequest) returns (google.protobuf.Empty);
|
rpc JoinCluster(JoinClusterRequest) returns (google.protobuf.Empty);
|
||||||
rpc Token(google.protobuf.Empty) returns (TokenResponse);
|
rpc Token(google.protobuf.Empty) returns (TokenResponse);
|
||||||
rpc Inspect(google.protobuf.Empty) returns (MachineInfo);
|
rpc Inspect(google.protobuf.Empty) returns (MachineInfo);
|
||||||
|
// Reset restores the machine to a clean state, removing all cluster-related сonfiguration and data.
|
||||||
|
rpc Reset(ResetRequest) returns (google.protobuf.Empty);
|
||||||
|
|
||||||
rpc InspectService(InspectServiceRequest) returns (InspectServiceResponse);
|
rpc InspectService(InspectServiceRequest) returns (InspectServiceResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +34,13 @@ message NetworkConfig {
|
|||||||
bytes public_key = 4;
|
bytes public_key = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CheckPrerequisitesResponse {
|
||||||
|
// Overall status of the checks.
|
||||||
|
bool satisfied = 1;
|
||||||
|
// Error message if not satisfied (empty if all checks pass).
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message InitClusterRequest {
|
message InitClusterRequest {
|
||||||
string machineName = 1;
|
string machineName = 1;
|
||||||
IPPrefix network = 2;
|
IPPrefix network = 2;
|
||||||
@@ -52,6 +64,9 @@ message TokenResponse {
|
|||||||
string token = 1;
|
string token = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ResetRequest {
|
||||||
|
}
|
||||||
|
|
||||||
message Service {
|
message Service {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
string name = 2;
|
string name = 2;
|
||||||
|
|||||||
@@ -20,21 +20,27 @@ import (
|
|||||||
const _ = grpc.SupportPackageIsVersion9
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Machine_InitCluster_FullMethodName = "/api.Machine/InitCluster"
|
Machine_CheckPrerequisites_FullMethodName = "/api.Machine/CheckPrerequisites"
|
||||||
Machine_JoinCluster_FullMethodName = "/api.Machine/JoinCluster"
|
Machine_InitCluster_FullMethodName = "/api.Machine/InitCluster"
|
||||||
Machine_Token_FullMethodName = "/api.Machine/Token"
|
Machine_JoinCluster_FullMethodName = "/api.Machine/JoinCluster"
|
||||||
Machine_Inspect_FullMethodName = "/api.Machine/Inspect"
|
Machine_Token_FullMethodName = "/api.Machine/Token"
|
||||||
Machine_InspectService_FullMethodName = "/api.Machine/InspectService"
|
Machine_Inspect_FullMethodName = "/api.Machine/Inspect"
|
||||||
|
Machine_Reset_FullMethodName = "/api.Machine/Reset"
|
||||||
|
Machine_InspectService_FullMethodName = "/api.Machine/InspectService"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MachineClient is the client API for Machine service.
|
// MachineClient is the client API for Machine service.
|
||||||
//
|
//
|
||||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
type MachineClient interface {
|
type MachineClient interface {
|
||||||
|
// CheckPrerequisites verifies if the machine meets all necessary system requirements to participate in the cluster.
|
||||||
|
CheckPrerequisites(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*CheckPrerequisitesResponse, error)
|
||||||
InitCluster(ctx context.Context, in *InitClusterRequest, opts ...grpc.CallOption) (*InitClusterResponse, error)
|
InitCluster(ctx context.Context, in *InitClusterRequest, opts ...grpc.CallOption) (*InitClusterResponse, error)
|
||||||
JoinCluster(ctx context.Context, in *JoinClusterRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
JoinCluster(ctx context.Context, in *JoinClusterRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
Token(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*TokenResponse, error)
|
Token(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*TokenResponse, error)
|
||||||
Inspect(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*MachineInfo, error)
|
Inspect(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*MachineInfo, error)
|
||||||
|
// Reset restores the machine to a clean state, removing all cluster-related сonfiguration and data.
|
||||||
|
Reset(ctx context.Context, in *ResetRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
InspectService(ctx context.Context, in *InspectServiceRequest, opts ...grpc.CallOption) (*InspectServiceResponse, error)
|
InspectService(ctx context.Context, in *InspectServiceRequest, opts ...grpc.CallOption) (*InspectServiceResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +52,16 @@ func NewMachineClient(cc grpc.ClientConnInterface) MachineClient {
|
|||||||
return &machineClient{cc}
|
return &machineClient{cc}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *machineClient) CheckPrerequisites(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*CheckPrerequisitesResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CheckPrerequisitesResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Machine_CheckPrerequisites_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *machineClient) InitCluster(ctx context.Context, in *InitClusterRequest, opts ...grpc.CallOption) (*InitClusterResponse, error) {
|
func (c *machineClient) InitCluster(ctx context.Context, in *InitClusterRequest, opts ...grpc.CallOption) (*InitClusterResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(InitClusterResponse)
|
out := new(InitClusterResponse)
|
||||||
@@ -86,6 +102,16 @@ func (c *machineClient) Inspect(ctx context.Context, in *emptypb.Empty, opts ...
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *machineClient) Reset(ctx context.Context, in *ResetRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(emptypb.Empty)
|
||||||
|
err := c.cc.Invoke(ctx, Machine_Reset_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *machineClient) InspectService(ctx context.Context, in *InspectServiceRequest, opts ...grpc.CallOption) (*InspectServiceResponse, error) {
|
func (c *machineClient) InspectService(ctx context.Context, in *InspectServiceRequest, opts ...grpc.CallOption) (*InspectServiceResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(InspectServiceResponse)
|
out := new(InspectServiceResponse)
|
||||||
@@ -100,10 +126,14 @@ func (c *machineClient) InspectService(ctx context.Context, in *InspectServiceRe
|
|||||||
// All implementations must embed UnimplementedMachineServer
|
// All implementations must embed UnimplementedMachineServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
type MachineServer interface {
|
type MachineServer interface {
|
||||||
|
// CheckPrerequisites verifies if the machine meets all necessary system requirements to participate in the cluster.
|
||||||
|
CheckPrerequisites(context.Context, *emptypb.Empty) (*CheckPrerequisitesResponse, error)
|
||||||
InitCluster(context.Context, *InitClusterRequest) (*InitClusterResponse, error)
|
InitCluster(context.Context, *InitClusterRequest) (*InitClusterResponse, error)
|
||||||
JoinCluster(context.Context, *JoinClusterRequest) (*emptypb.Empty, error)
|
JoinCluster(context.Context, *JoinClusterRequest) (*emptypb.Empty, error)
|
||||||
Token(context.Context, *emptypb.Empty) (*TokenResponse, error)
|
Token(context.Context, *emptypb.Empty) (*TokenResponse, error)
|
||||||
Inspect(context.Context, *emptypb.Empty) (*MachineInfo, error)
|
Inspect(context.Context, *emptypb.Empty) (*MachineInfo, error)
|
||||||
|
// Reset restores the machine to a clean state, removing all cluster-related сonfiguration and data.
|
||||||
|
Reset(context.Context, *ResetRequest) (*emptypb.Empty, error)
|
||||||
InspectService(context.Context, *InspectServiceRequest) (*InspectServiceResponse, error)
|
InspectService(context.Context, *InspectServiceRequest) (*InspectServiceResponse, error)
|
||||||
mustEmbedUnimplementedMachineServer()
|
mustEmbedUnimplementedMachineServer()
|
||||||
}
|
}
|
||||||
@@ -115,6 +145,9 @@ type MachineServer interface {
|
|||||||
// pointer dereference when methods are called.
|
// pointer dereference when methods are called.
|
||||||
type UnimplementedMachineServer struct{}
|
type UnimplementedMachineServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedMachineServer) CheckPrerequisites(context.Context, *emptypb.Empty) (*CheckPrerequisitesResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method CheckPrerequisites not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedMachineServer) InitCluster(context.Context, *InitClusterRequest) (*InitClusterResponse, error) {
|
func (UnimplementedMachineServer) InitCluster(context.Context, *InitClusterRequest) (*InitClusterResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method InitCluster not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method InitCluster not implemented")
|
||||||
}
|
}
|
||||||
@@ -127,6 +160,9 @@ func (UnimplementedMachineServer) Token(context.Context, *emptypb.Empty) (*Token
|
|||||||
func (UnimplementedMachineServer) Inspect(context.Context, *emptypb.Empty) (*MachineInfo, error) {
|
func (UnimplementedMachineServer) Inspect(context.Context, *emptypb.Empty) (*MachineInfo, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method Inspect not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method Inspect not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedMachineServer) Reset(context.Context, *ResetRequest) (*emptypb.Empty, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method Reset not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedMachineServer) InspectService(context.Context, *InspectServiceRequest) (*InspectServiceResponse, error) {
|
func (UnimplementedMachineServer) InspectService(context.Context, *InspectServiceRequest) (*InspectServiceResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method InspectService not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method InspectService not implemented")
|
||||||
}
|
}
|
||||||
@@ -151,6 +187,24 @@ func RegisterMachineServer(s grpc.ServiceRegistrar, srv MachineServer) {
|
|||||||
s.RegisterService(&Machine_ServiceDesc, srv)
|
s.RegisterService(&Machine_ServiceDesc, srv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _Machine_CheckPrerequisites_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(emptypb.Empty)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MachineServer).CheckPrerequisites(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Machine_CheckPrerequisites_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MachineServer).CheckPrerequisites(ctx, req.(*emptypb.Empty))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _Machine_InitCluster_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _Machine_InitCluster_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(InitClusterRequest)
|
in := new(InitClusterRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -223,6 +277,24 @@ func _Machine_Inspect_Handler(srv interface{}, ctx context.Context, dec func(int
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _Machine_Reset_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ResetRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MachineServer).Reset(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Machine_Reset_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MachineServer).Reset(ctx, req.(*ResetRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _Machine_InspectService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _Machine_InspectService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(InspectServiceRequest)
|
in := new(InspectServiceRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -248,6 +320,10 @@ var Machine_ServiceDesc = grpc.ServiceDesc{
|
|||||||
ServiceName: "api.Machine",
|
ServiceName: "api.Machine",
|
||||||
HandlerType: (*MachineServer)(nil),
|
HandlerType: (*MachineServer)(nil),
|
||||||
Methods: []grpc.MethodDesc{
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "CheckPrerequisites",
|
||||||
|
Handler: _Machine_CheckPrerequisites_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "InitCluster",
|
MethodName: "InitCluster",
|
||||||
Handler: _Machine_InitCluster_Handler,
|
Handler: _Machine_InitCluster_Handler,
|
||||||
@@ -264,6 +340,10 @@ var Machine_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "Inspect",
|
MethodName: "Inspect",
|
||||||
Handler: _Machine_Inspect_Handler,
|
Handler: _Machine_Inspect_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Reset",
|
||||||
|
Handler: _Machine_Reset_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "InspectService",
|
MethodName: "InspectService",
|
||||||
Handler: _Machine_InspectService_Handler,
|
Handler: _Machine_InspectService_Handler,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package caddyfile
|
package caddyconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -10,12 +10,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/caddyserver/caddy/v2"
|
"github.com/caddyserver/caddy/v2"
|
||||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,34 +24,38 @@ func GenerateConfig(containers []api.ServiceContainer, verifyResponse string) (*
|
|||||||
httpHostUpstreams := make(map[string][]string)
|
httpHostUpstreams := make(map[string][]string)
|
||||||
httpsHostUpstreams := make(map[string][]string)
|
httpsHostUpstreams := make(map[string][]string)
|
||||||
for _, ctr := range containers {
|
for _, ctr := range containers {
|
||||||
logger := slog.With("container", ctr.ID)
|
if !ctr.Healthy() {
|
||||||
network, ok := ctr.NetworkSettings.Networks[docker.NetworkName]
|
|
||||||
if !ok {
|
|
||||||
// Container is not connected to the uncloud Docker network (could be host network).
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if network.IPAddress == "" {
|
|
||||||
logger.Error("Container has no IPv4 address.")
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ip := ctr.UncloudNetworkIP()
|
||||||
|
if !ip.IsValid() {
|
||||||
|
// Container is not connected to the uncloud Docker network (could be host network).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log := slog.With("container", ctr.ID)
|
||||||
|
|
||||||
ports, err := ctr.ServicePorts()
|
ports, err := ctr.ServicePorts()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to parse service ports for container.", "err", err)
|
log.Error("Failed to parse service ports for container.", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
|
if port.Mode != api.PortModeIngress {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
switch port.Protocol {
|
switch port.Protocol {
|
||||||
case api.ProtocolHTTP:
|
case api.ProtocolHTTP:
|
||||||
upstream := net.JoinHostPort(network.IPAddress, strconv.Itoa(int(port.ContainerPort)))
|
upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
|
||||||
httpHostUpstreams[port.Hostname] = append(httpHostUpstreams[port.Hostname], upstream)
|
httpHostUpstreams[port.Hostname] = append(httpHostUpstreams[port.Hostname], upstream)
|
||||||
case api.ProtocolHTTPS:
|
case api.ProtocolHTTPS:
|
||||||
upstream := net.JoinHostPort(network.IPAddress, strconv.Itoa(int(port.ContainerPort)))
|
upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
|
||||||
httpsHostUpstreams[port.Hostname] = append(httpsHostUpstreams[port.Hostname], upstream)
|
httpsHostUpstreams[port.Hostname] = append(httpsHostUpstreams[port.Hostname], upstream)
|
||||||
default:
|
default:
|
||||||
// TODO: implement L4 ingress routing for TCP and UDP.
|
// TODO: implement L4 ingress routing for TCP and UDP.
|
||||||
logger.Error("Unsupported protocol for ingress port.", "port", port)
|
log.Error("Unsupported protocol for ingress port.", "port", port)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,8 @@ func GenerateConfig(containers []api.ServiceContainer, verifyResponse string) (*
|
|||||||
servers := make(map[string]*caddyhttp.Server)
|
servers := make(map[string]*caddyhttp.Server)
|
||||||
servers["http"] = &caddyhttp.Server{
|
servers["http"] = &caddyhttp.Server{
|
||||||
Listen: []string{fmt.Sprintf(":%d", caddyhttp.DefaultHTTPPort)},
|
Listen: []string{fmt.Sprintf(":%d", caddyhttp.DefaultHTTPPort)},
|
||||||
|
// All http requests to this server are logged to the default logger.
|
||||||
|
Logs: &caddyhttp.ServerLogConfig{},
|
||||||
Routes: append(
|
Routes: append(
|
||||||
hostUpstreamsToRoutes(httpHostUpstreams, &warnings),
|
hostUpstreamsToRoutes(httpHostUpstreams, &warnings),
|
||||||
// Add a route to respond with a static verification response at the /.uncloud-verify path.
|
// Add a route to respond with a static verification response at the /.uncloud-verify path.
|
||||||
@@ -69,6 +75,8 @@ func GenerateConfig(containers []api.ServiceContainer, verifyResponse string) (*
|
|||||||
}
|
}
|
||||||
servers["https"] = &caddyhttp.Server{
|
servers["https"] = &caddyhttp.Server{
|
||||||
Listen: []string{fmt.Sprintf(":%d", caddyhttp.DefaultHTTPSPort)},
|
Listen: []string{fmt.Sprintf(":%d", caddyhttp.DefaultHTTPSPort)},
|
||||||
|
// All https requests to this server are logged to the default logger.
|
||||||
|
Logs: &caddyhttp.ServerLogConfig{},
|
||||||
Routes: hostUpstreamsToRoutes(httpsHostUpstreams, &warnings),
|
Routes: hostUpstreamsToRoutes(httpsHostUpstreams, &warnings),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +117,16 @@ func hostUpstreamsToRoutes(hostUpstreams map[string][]string, warnings *[]caddyc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
handler := &reverseproxy.Handler{
|
handler := &reverseproxy.Handler{
|
||||||
|
HealthChecks: &reverseproxy.HealthChecks{
|
||||||
|
// Enable passive health checks to automatically detect unhealthy upstreams.
|
||||||
|
Passive: &reverseproxy.PassiveHealthChecks{
|
||||||
|
FailDuration: caddy.Duration(30 * time.Second),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LoadBalancing: &reverseproxy.LoadBalancing{
|
||||||
|
// Retry failed requests to skip over temporarily unavailable upstreams.
|
||||||
|
Retries: 3,
|
||||||
|
},
|
||||||
Upstreams: upstreamPool,
|
Upstreams: upstreamPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
+167
-76
@@ -1,4 +1,4 @@
|
|||||||
package caddyfile
|
package caddyconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +14,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateConfig(t *testing.T) {
|
func TestGenerateConfig(t *testing.T) {
|
||||||
|
configWithoutServices := `{
|
||||||
|
"servers": {
|
||||||
|
"http": {
|
||||||
|
"listen": [":80"],
|
||||||
|
"routes": [{
|
||||||
|
"match": [{"path": ["/.uncloud-verify"]}],
|
||||||
|
"handle": [{
|
||||||
|
"body": "verification-response-body",
|
||||||
|
"handler": "static_response",
|
||||||
|
"status_code": 200
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"logs": {}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"listen": [":443"],
|
||||||
|
"logs": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
containers []api.ServiceContainer
|
containers []api.ServiceContainer
|
||||||
@@ -23,25 +44,8 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "empty containers",
|
name: "empty containers",
|
||||||
containers: []api.ServiceContainer{},
|
containers: []api.ServiceContainer{},
|
||||||
want: `{
|
want: configWithoutServices,
|
||||||
"servers": {
|
wantErr: false,
|
||||||
"http": {
|
|
||||||
"listen": [":80"],
|
|
||||||
"routes": [{
|
|
||||||
"match": [{"path": ["/.uncloud-verify"]}],
|
|
||||||
"handle": [{
|
|
||||||
"body": "verification-response-body",
|
|
||||||
"handler": "static_response",
|
|
||||||
"status_code": 200
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"listen": [":443"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -58,6 +62,14 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
"match": [{"host": ["app.example.com"]}],
|
"match": [{"host": ["app.example.com"]}],
|
||||||
"handle": [{
|
"handle": [{
|
||||||
"handler": "reverse_proxy",
|
"handler": "reverse_proxy",
|
||||||
|
"health_checks": {
|
||||||
|
"passive": {
|
||||||
|
"fail_duration": 30000000000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"load_balancing": {
|
||||||
|
"retries": 3
|
||||||
|
},
|
||||||
"upstreams": [{"dial": "10.210.0.2:8080"}]
|
"upstreams": [{"dial": "10.210.0.2:8080"}]
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
@@ -69,10 +81,12 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
"status_code": 200
|
"status_code": 200
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"logs": {}
|
||||||
},
|
},
|
||||||
"https": {
|
"https": {
|
||||||
"listen": [":443"]
|
"listen": [":443"],
|
||||||
|
"logs": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -93,6 +107,14 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
"match": [{"host": ["app.example.com"]}],
|
"match": [{"host": ["app.example.com"]}],
|
||||||
"handle": [{
|
"handle": [{
|
||||||
"handler": "reverse_proxy",
|
"handler": "reverse_proxy",
|
||||||
|
"health_checks": {
|
||||||
|
"passive": {
|
||||||
|
"fail_duration": 30000000000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"load_balancing": {
|
||||||
|
"retries": 3
|
||||||
|
},
|
||||||
"upstreams": [
|
"upstreams": [
|
||||||
{"dial": "10.210.0.2:8080"},
|
{"dial": "10.210.0.2:8080"},
|
||||||
{"dial": "10.210.0.3:8080"}
|
{"dial": "10.210.0.3:8080"}
|
||||||
@@ -107,10 +129,12 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
"status_code": 200
|
"status_code": 200
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"logs": {}
|
||||||
},
|
},
|
||||||
"https": {
|
"https": {
|
||||||
"listen": [":443"]
|
"listen": [":443"],
|
||||||
|
"logs": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -134,7 +158,8 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
"status_code": 200
|
"status_code": 200
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"logs": {}
|
||||||
},
|
},
|
||||||
"https": {
|
"https": {
|
||||||
"listen": [":443"],
|
"listen": [":443"],
|
||||||
@@ -143,10 +168,19 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
"match": [{"host": ["secure.example.com"]}],
|
"match": [{"host": ["secure.example.com"]}],
|
||||||
"handle": [{
|
"handle": [{
|
||||||
"handler": "reverse_proxy",
|
"handler": "reverse_proxy",
|
||||||
|
"health_checks": {
|
||||||
|
"passive": {
|
||||||
|
"fail_duration": 30000000000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"load_balancing": {
|
||||||
|
"retries": 3
|
||||||
|
},
|
||||||
"upstreams": [{"dial": "10.210.0.2:8000"}]
|
"upstreams": [{"dial": "10.210.0.2:8000"}]
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"logs": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -178,6 +212,14 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
"match": [{"host": ["app.example.com"]}],
|
"match": [{"host": ["app.example.com"]}],
|
||||||
"handle": [{
|
"handle": [{
|
||||||
"handler": "reverse_proxy",
|
"handler": "reverse_proxy",
|
||||||
|
"health_checks": {
|
||||||
|
"passive": {
|
||||||
|
"fail_duration": 30000000000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"load_balancing": {
|
||||||
|
"retries": 3
|
||||||
|
},
|
||||||
"upstreams": [
|
"upstreams": [
|
||||||
{"dial": "10.210.0.2:8080"},
|
{"dial": "10.210.0.2:8080"},
|
||||||
{"dial": "10.210.0.3:8080"},
|
{"dial": "10.210.0.3:8080"},
|
||||||
@@ -189,6 +231,14 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
"match": [{"host": ["web.example.com"]}],
|
"match": [{"host": ["web.example.com"]}],
|
||||||
"handle": [{
|
"handle": [{
|
||||||
"handler": "reverse_proxy",
|
"handler": "reverse_proxy",
|
||||||
|
"health_checks": {
|
||||||
|
"passive": {
|
||||||
|
"fail_duration": 30000000000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"load_balancing": {
|
||||||
|
"retries": 3
|
||||||
|
},
|
||||||
"upstreams": [
|
"upstreams": [
|
||||||
{"dial": "10.210.0.2:8000"},
|
{"dial": "10.210.0.2:8000"},
|
||||||
{"dial": "10.210.0.4:8000"},
|
{"dial": "10.210.0.4:8000"},
|
||||||
@@ -204,7 +254,8 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
"status_code": 200
|
"status_code": 200
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"logs": {}
|
||||||
},
|
},
|
||||||
"https": {
|
"https": {
|
||||||
"listen": [":443"],
|
"listen": [":443"],
|
||||||
@@ -213,6 +264,14 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
"match": [{"host": ["secure.example.com"]}],
|
"match": [{"host": ["secure.example.com"]}],
|
||||||
"handle": [{
|
"handle": [{
|
||||||
"handler": "reverse_proxy",
|
"handler": "reverse_proxy",
|
||||||
|
"health_checks": {
|
||||||
|
"passive": {
|
||||||
|
"fail_duration": 30000000000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"load_balancing": {
|
||||||
|
"retries": 3
|
||||||
|
},
|
||||||
"upstreams": [
|
"upstreams": [
|
||||||
{"dial": "10.210.0.3:8888"},
|
{"dial": "10.210.0.3:8888"},
|
||||||
{"dial": "10.210.0.4:8888"},
|
{"dial": "10.210.0.4:8888"},
|
||||||
@@ -220,7 +279,8 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"logs": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -231,24 +291,7 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
containers: []api.ServiceContainer{
|
containers: []api.ServiceContainer{
|
||||||
newContainerWithoutNetwork("ignored.example.com:8080/http"),
|
newContainerWithoutNetwork("ignored.example.com:8080/http"),
|
||||||
},
|
},
|
||||||
want: `{
|
want: configWithoutServices,
|
||||||
"servers": {
|
|
||||||
"http": {
|
|
||||||
"listen": [":80"],
|
|
||||||
"routes": [{
|
|
||||||
"match": [{"path": ["/.uncloud-verify"]}],
|
|
||||||
"handle": [{
|
|
||||||
"body": "verification-response-body",
|
|
||||||
"handler": "static_response",
|
|
||||||
"status_code": 200
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"listen": [":443"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -256,48 +299,76 @@ func TestGenerateConfig(t *testing.T) {
|
|||||||
containers: []api.ServiceContainer{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "invalid-port"),
|
newContainer("10.210.0.2", "invalid-port"),
|
||||||
},
|
},
|
||||||
want: `{
|
want: configWithoutServices,
|
||||||
"servers": {
|
|
||||||
"http": {
|
|
||||||
"listen": [":80"],
|
|
||||||
"routes": [{
|
|
||||||
"match": [{"path": ["/.uncloud-verify"]}],
|
|
||||||
"handle": [{
|
|
||||||
"body": "verification-response-body",
|
|
||||||
"handler": "static_response",
|
|
||||||
"status_code": 200
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"listen": [":443"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "containers with unsupported protocols ignored",
|
name: "containers with unsupported protocols and host mode ignored",
|
||||||
containers: []api.ServiceContainer{
|
containers: []api.ServiceContainer{
|
||||||
newContainer("10.210.0.2", "5000/tcp"),
|
newContainer("10.210.0.2", "5000/tcp"),
|
||||||
newContainer("10.210.0.3", "5000/udp"),
|
newContainer("10.210.0.3", "5000/udp"),
|
||||||
newContainer("10.210.0.4", "80:8080/tcp@host"),
|
newContainer("10.210.0.4", "80:8080/tcp@host"),
|
||||||
},
|
},
|
||||||
|
want: configWithoutServices,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "restarting container ignored",
|
||||||
|
containers: []api.ServiceContainer{
|
||||||
|
newRestartingContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||||
|
},
|
||||||
|
want: configWithoutServices,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stopped container ignored",
|
||||||
|
containers: []api.ServiceContainer{
|
||||||
|
newStoppedContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||||
|
},
|
||||||
|
want: configWithoutServices,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mix of running, restarting, and stopped containers",
|
||||||
|
containers: []api.ServiceContainer{
|
||||||
|
newContainer("10.210.0.2", "app.example.com:8080/http"),
|
||||||
|
newRestartingContainer("10.210.0.3", "app.example.com:8080/http"),
|
||||||
|
newStoppedContainer("10.210.0.4", "app.example.com:8080/http"),
|
||||||
|
},
|
||||||
want: `{
|
want: `{
|
||||||
"servers": {
|
"servers": {
|
||||||
"http": {
|
"http": {
|
||||||
"listen": [":80"],
|
"listen": [":80"],
|
||||||
"routes": [{
|
"routes": [
|
||||||
"match": [{"path": ["/.uncloud-verify"]}],
|
{
|
||||||
"handle": [{
|
"match": [{"host": ["app.example.com"]}],
|
||||||
"body": "verification-response-body",
|
"handle": [{
|
||||||
"handler": "static_response",
|
"handler": "reverse_proxy",
|
||||||
"status_code": 200
|
"health_checks": {
|
||||||
}]
|
"passive": {
|
||||||
}]
|
"fail_duration": 30000000000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"load_balancing": {
|
||||||
|
"retries": 3
|
||||||
|
},
|
||||||
|
"upstreams": [{"dial": "10.210.0.2:8080"}]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": [{"path": ["/.uncloud-verify"]}],
|
||||||
|
"handle": [{
|
||||||
|
"body": "verification-response-body",
|
||||||
|
"handler": "static_response",
|
||||||
|
"status_code": 200
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"logs": {}
|
||||||
},
|
},
|
||||||
"https": {
|
"https": {
|
||||||
"listen": [":443"]
|
"listen": [":443"],
|
||||||
|
"logs": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -326,7 +397,11 @@ func TestGenerateConfig(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{ContainerJSON: types.ContainerJSON{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{},
|
ContainerJSONBase: &types.ContainerJSONBase{
|
||||||
|
State: &types.ContainerState{
|
||||||
|
Running: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &types.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
docker.NetworkName: {
|
docker.NetworkName: {
|
||||||
@@ -345,7 +420,11 @@ 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{ContainerJSON: types.ContainerJSON{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{},
|
ContainerJSONBase: &types.ContainerJSONBase{
|
||||||
|
State: &types.ContainerState{
|
||||||
|
Running: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &types.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
"other-network": {
|
"other-network": {
|
||||||
@@ -360,3 +439,15 @@ func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
|
|||||||
},
|
},
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newRestartingContainer(ip string, ports ...string) api.ServiceContainer {
|
||||||
|
ctr := newContainer(ip, ports...)
|
||||||
|
ctr.Container.State.Restarting = true
|
||||||
|
return ctr
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStoppedContainer(ip string, ports ...string) api.ServiceContainer {
|
||||||
|
ctr := newContainer(ip, ports...)
|
||||||
|
ctr.Container.State.Running = false
|
||||||
|
return ctr
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package caddyfile
|
package caddyconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -19,7 +19,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Controller monitors container changes in the cluster store and generates a configuration file for Caddy reverse
|
// Controller monitors container changes in the cluster store and generates a configuration file for Caddy reverse
|
||||||
// proxy. The generated Caddyfile allows Caddy to route external traffic to service containers across the internal
|
// proxy. The generated configuration allows Caddy to route external traffic to service containers across the internal
|
||||||
// network.
|
// network.
|
||||||
type Controller struct {
|
type Controller struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterResolver implements Resolver by tracking containers in the cluster and resolving service names
|
||||||
|
// to their IP addresses.
|
||||||
|
type ClusterResolver struct {
|
||||||
|
store *store.Store
|
||||||
|
// serviceIPs maps service names to container IPs.
|
||||||
|
serviceIPs map[string][]netip.Addr
|
||||||
|
// mu protects the serviceIPs map.
|
||||||
|
mu sync.RWMutex
|
||||||
|
// lastUpdate tracks when records were last updated.
|
||||||
|
lastUpdate time.Time
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClusterResolver creates a new cluster resolver using the cluster store.
|
||||||
|
func NewClusterResolver(store *store.Store) *ClusterResolver {
|
||||||
|
return &ClusterResolver{
|
||||||
|
store: store,
|
||||||
|
serviceIPs: make(map[string][]netip.Addr),
|
||||||
|
log: slog.With("component", "dns-resolver"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts watching for container changes and updates DNS records accordingly.
|
||||||
|
func (r *ClusterResolver) Run(ctx context.Context) error {
|
||||||
|
containers, changes, err := r.store.SubscribeContainers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("subscribe to container changes: %w", err)
|
||||||
|
}
|
||||||
|
r.log.Info("Subscribed to container changes in the cluster to keep DNS records updated.")
|
||||||
|
|
||||||
|
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
||||||
|
r.updateServiceIPs(containers)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case _, ok := <-changes:
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("containers subscription failed")
|
||||||
|
}
|
||||||
|
r.log.Debug("Cluster containers changed, updating DNS records.")
|
||||||
|
|
||||||
|
containers, err = r.store.ListContainers(ctx, store.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
r.log.Error("Failed to list containers.", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
||||||
|
r.updateServiceIPs(containers)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateServiceIPs processes container records and updates the serviceIPs map.
|
||||||
|
func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
||||||
|
newServiceIPs := make(map[string][]netip.Addr, len(r.serviceIPs))
|
||||||
|
|
||||||
|
containersCount := 0
|
||||||
|
for _, record := range containers {
|
||||||
|
if !record.Container.Healthy() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := record.Container.UncloudNetworkIP()
|
||||||
|
if !ip.IsValid() {
|
||||||
|
// Container is not connected to the uncloud Docker network (could be host network).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ctr := api.ServiceContainer{Container: record.Container}
|
||||||
|
if ctr.ServiceID() == "" || ctr.ServiceName() == "" {
|
||||||
|
// Container is not part of a service, skip it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: remove normalisation after implementing service name validation:
|
||||||
|
//.https://github.com/psviderski/uncloud/issues/53
|
||||||
|
serviceName := strings.ToLower(ctr.ServiceName())
|
||||||
|
|
||||||
|
newServiceIPs[serviceName] = append(newServiceIPs[serviceName], ip)
|
||||||
|
// Also add the service ID as a valid lookup.
|
||||||
|
newServiceIPs[ctr.ServiceID()] = append(newServiceIPs[ctr.ServiceID()], ip)
|
||||||
|
containersCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the serviceIPs map atomically.
|
||||||
|
r.mu.Lock()
|
||||||
|
r.serviceIPs = newServiceIPs
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
r.log.Debug("DNS records updated.", "services", len(newServiceIPs)/2, "containers", containersCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns IP addresses of the service containers.
|
||||||
|
func (r *ClusterResolver) Resolve(serviceName string) []netip.Addr {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
ips, ok := r.serviceIPs[serviceName]
|
||||||
|
if !ok || len(ips) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a copy of the IPs slice to prevent modification of the original.
|
||||||
|
ipsCopy := make([]netip.Addr, len(ips))
|
||||||
|
copy(ipsCopy, ips)
|
||||||
|
|
||||||
|
return ipsCopy
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// InternalDomain is the cluster internal domain for service discovery. All DNS queries ending with this suffix
|
||||||
|
// will be resolved using the internal DNS server.
|
||||||
|
InternalDomain = "internal."
|
||||||
|
// Port is the standard DNS port.
|
||||||
|
Port = 53
|
||||||
|
// maxConcurrentForwards is the maximum number of concurrent forwarded queries to upstream DNS servers.
|
||||||
|
// 1024 is the default used by the Docker internal DNS server.
|
||||||
|
maxConcurrentForwards = 1024
|
||||||
|
// forwardingTimeout is the timeout for forwarding a DNS query to an upstream server.
|
||||||
|
forwardingTimeout = 3 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolver is an interface for resolving service names to IP addresses.
|
||||||
|
type Resolver interface {
|
||||||
|
// Resolve returns a list of IP addresses of the service containers.
|
||||||
|
// An empty list is returned if no service is found.
|
||||||
|
Resolve(serviceName string) []netip.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is an embedded internal DNS server for service discovery and forwarding external queries
|
||||||
|
// to upstream DNS servers.
|
||||||
|
type Server struct {
|
||||||
|
listenAddr netip.Addr
|
||||||
|
resolver Resolver
|
||||||
|
upstreamServers []netip.AddrPort
|
||||||
|
|
||||||
|
udpServer *dns.Server
|
||||||
|
tcpServer *dns.Server
|
||||||
|
inProgressReqs sync.WaitGroup
|
||||||
|
forwardSemaphore chan struct{}
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new DNS server with the given configuration.
|
||||||
|
// If upstreams is nil, nameservers from /etc/resolv.conf will be used. An empty upstreams list means to only resolve
|
||||||
|
// internal DNS queries and not forward any external queries.
|
||||||
|
func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) {
|
||||||
|
if !listenAddr.IsValid() {
|
||||||
|
return nil, fmt.Errorf("invalid listen address: %s", listenAddr)
|
||||||
|
}
|
||||||
|
if resolver == nil {
|
||||||
|
return nil, fmt.Errorf("resolver must be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load upstreams from /etc/resolv.conf and set fallback servers only if upstreams is not provided.
|
||||||
|
if upstreams == nil {
|
||||||
|
if nameservers, err := parseNameserversFromResolvConf(); err != nil {
|
||||||
|
slog.Warn("Failed to parse nameservers from /etc/resolv.conf.", "err", err)
|
||||||
|
} else {
|
||||||
|
for _, ns := range nameservers {
|
||||||
|
if ns.Compare(listenAddr) == 0 {
|
||||||
|
// Skip this internal DNS server address if it has been configured in /etc/resolv.conf
|
||||||
|
// to not create a forwarding loop.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
upstreams = append(upstreams, netip.AddrPortFrom(ns, Port))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to common public DNS servers if no nameservers were found in /etc/resolv.conf.
|
||||||
|
if upstreams == nil {
|
||||||
|
upstreams = []netip.AddrPort{
|
||||||
|
netip.AddrPortFrom(netip.MustParseAddr("1.1.1.1"), Port), // Cloudflare DNS
|
||||||
|
netip.AddrPortFrom(netip.MustParseAddr("8.8.8.8"), Port), // Google DNS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
listenAddr: listenAddr,
|
||||||
|
resolver: resolver,
|
||||||
|
upstreamServers: upstreams,
|
||||||
|
forwardSemaphore: make(chan struct{}, maxConcurrentForwards),
|
||||||
|
log: slog.With("component", "dns-server"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAddr returns the address the DNS server is listening on.
|
||||||
|
func (s *Server) ListenAddr() netip.Addr {
|
||||||
|
return s.listenAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the DNS server listening on both UDP and TCP ports. The server on TCP is not critical so it won't return
|
||||||
|
// an error if it fails to start. The server will run until the context is canceled or an error occurs.
|
||||||
|
func (s *Server) Run(ctx context.Context) error {
|
||||||
|
addr := net.JoinHostPort(s.listenAddr.String(), strconv.Itoa(Port))
|
||||||
|
s.udpServer = &dns.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Net: "udp",
|
||||||
|
Handler: dns.HandlerFunc(s.handleRequest),
|
||||||
|
}
|
||||||
|
s.tcpServer = &dns.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Net: "tcp",
|
||||||
|
Handler: dns.HandlerFunc(s.handleRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1) // Buffer size 1 is for UDP server error only.
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
s.log.Info("Starting DNS server on UDP port.", "addr", addr, "upstreams", s.upstreamServers)
|
||||||
|
if err := s.udpServer.ListenAndServe(); err != nil {
|
||||||
|
errCh <- fmt.Errorf("listen and serve on %s/udp: %w", addr, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
s.log.Info("Starting DNS server on TCP port.", "addr", addr, "upstreams", s.upstreamServers)
|
||||||
|
if err := s.tcpServer.ListenAndServe(); err != nil {
|
||||||
|
// TCP server is not critical, so log the error and continue.
|
||||||
|
slog.Warn("Failed to listen and serve DNS server on TCP port. "+
|
||||||
|
"TCP server is not critical and will be ignored.", "addr", addr, "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
// Stop the servers if the UDP one fails.
|
||||||
|
s.stop()
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.log.Info("Stopping DNS server.")
|
||||||
|
return s.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop gracefully shuts down the DNS server.
|
||||||
|
func (s *Server) stop() error {
|
||||||
|
var udpErr, tcpErr error
|
||||||
|
|
||||||
|
if s.udpServer != nil {
|
||||||
|
udpErr = s.udpServer.Shutdown()
|
||||||
|
}
|
||||||
|
if s.tcpServer != nil {
|
||||||
|
tcpErr = s.tcpServer.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all in-progress requests to finish.
|
||||||
|
s.inProgressReqs.Wait()
|
||||||
|
|
||||||
|
if udpErr != nil {
|
||||||
|
return fmt.Errorf("shutdown DNS server (UDP): %w", udpErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tcpErr != nil {
|
||||||
|
return fmt.Errorf("shutdown DNS server (TCP): %w", tcpErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequest processes a DNS query and returns an appropriate response.
|
||||||
|
func (s *Server) handleRequest(w dns.ResponseWriter, req *dns.Msg) {
|
||||||
|
s.inProgressReqs.Add(1)
|
||||||
|
defer s.inProgressReqs.Done()
|
||||||
|
|
||||||
|
if len(req.Question) == 0 {
|
||||||
|
resp := new(dns.Msg).SetRcode(req, dns.RcodeFormatError)
|
||||||
|
s.reply(w, req, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// While the original DNS RFCs allow multiple questions, in practice it never works. So handle only the first one.
|
||||||
|
q := req.Question[0]
|
||||||
|
log := s.log.With("name", q.Name, "type", dns.TypeToString[q.Qtype])
|
||||||
|
log.Debug("Received DNS query.")
|
||||||
|
|
||||||
|
if !dns.IsSubDomain(InternalDomain, dns.CanonicalName(q.Name)) {
|
||||||
|
log.Debug("Forwarding non-internal DNS query to upstream DNS servers.")
|
||||||
|
|
||||||
|
// Use the same transport for the forwarded request as the original request.
|
||||||
|
resp, err := s.forwardRequest(req, w.LocalAddr().Network())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to forward DNS query.", "err", err)
|
||||||
|
resp = new(dns.Msg).SetRcode(req, dns.RcodeServerFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.reply(w, req, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the query for the internal domain.
|
||||||
|
resp := new(dns.Msg).SetReply(req)
|
||||||
|
resp.Authoritative = true
|
||||||
|
resp.RecursionAvailable = true
|
||||||
|
|
||||||
|
switch q.Qtype {
|
||||||
|
case dns.TypeA:
|
||||||
|
records := s.handleAQuery(q.Name)
|
||||||
|
if len(records) > 0 {
|
||||||
|
log.Debug("Found A records for internal DNS query.", "count", len(records))
|
||||||
|
resp.Answer = append(resp.Answer, records...)
|
||||||
|
} else {
|
||||||
|
log.Debug("No records found for internal DNS query.")
|
||||||
|
resp.SetRcode(req, dns.RcodeNameError)
|
||||||
|
}
|
||||||
|
// TODO: Handle other query types (SRV, TXT, etc.) as needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate the response if it exceeds the maximum size for the transport protocol.
|
||||||
|
maxSize := dns.MinMsgSize
|
||||||
|
if w.LocalAddr().Network() == "tcp" {
|
||||||
|
maxSize = dns.MaxMsgSize
|
||||||
|
} else {
|
||||||
|
// Retrieve the UDP buffer size from the EDNS0 record if present.
|
||||||
|
if opt := req.IsEdns0(); opt != nil {
|
||||||
|
if udpSize := int(opt.UDPSize()); udpSize > maxSize {
|
||||||
|
maxSize = udpSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.Truncate(maxSize)
|
||||||
|
|
||||||
|
s.reply(w, req, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) reply(w dns.ResponseWriter, req *dns.Msg, resp *dns.Msg) error {
|
||||||
|
if err := w.WriteMsg(resp); err != nil {
|
||||||
|
s.log.Error("Failed to write DNS response.", "err", err, "msg", resp)
|
||||||
|
// It may fail due to a malformed response message, e.g. exceeding the maximum size. In that case,
|
||||||
|
// attempt to send a server failure response instead so the client doesn't have to wait for a timeout.
|
||||||
|
if resp.Rcode != dns.RcodeServerFailure {
|
||||||
|
resp = new(dns.Msg).SetRcode(req, dns.RcodeServerFailure)
|
||||||
|
if err2 := w.WriteMsg(resp); err2 != nil {
|
||||||
|
s.log.Error("Failed to write DNS error response.", "err", err2, "msg", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardRequest forwards a DNS query to system DNS servers
|
||||||
|
func (s *Server) forwardRequest(req *dns.Msg, proto string) (*dns.Msg, error) {
|
||||||
|
if len(s.upstreamServers) == 0 {
|
||||||
|
return nil, errors.New("no upstream DNS servers configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply concurrency control for forwarded queries.
|
||||||
|
select {
|
||||||
|
case s.forwardSemaphore <- struct{}{}:
|
||||||
|
defer func() {
|
||||||
|
<-s.forwardSemaphore
|
||||||
|
}()
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("too many concurrent forwarded queries (max: %d)", maxConcurrentForwards)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create DNS client with timeout and same transport as original request
|
||||||
|
client := &dns.Client{
|
||||||
|
Net: proto,
|
||||||
|
Timeout: forwardingTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, server := range s.upstreamServers {
|
||||||
|
resp, _, err := client.Exchange(req, server.String())
|
||||||
|
if err == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
s.log.Debug("Failed to forward DNS query to upstream server.", "server", server, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAQuery processes an A query for the internal domain and returns A records for the requested name.
|
||||||
|
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
|
||||||
|
func (s *Server) handleAQuery(name string) []dns.RR {
|
||||||
|
serviceName := trimInternalDomain(name)
|
||||||
|
ips := s.resolver.Resolve(serviceName)
|
||||||
|
if len(ips) == 0 {
|
||||||
|
s.log.Debug("Failed to resolve service name.", "service", serviceName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips)
|
||||||
|
|
||||||
|
if len(ips) > 1 {
|
||||||
|
// TODO: sort by proximity to the requesting container/machine. For now, just shuffle the IPs.
|
||||||
|
rand.Shuffle(len(ips), func(i, j int) {
|
||||||
|
ips[i], ips[j] = ips[j], ips[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create A records for each IP.
|
||||||
|
records := make([]dns.RR, 0, len(ips))
|
||||||
|
for _, ip := range ips {
|
||||||
|
records = append(records, &dns.A{
|
||||||
|
Hdr: dns.RR_Header{
|
||||||
|
Name: name,
|
||||||
|
Rrtype: dns.TypeA,
|
||||||
|
Class: dns.ClassINET,
|
||||||
|
// TODO: should we increate the TTL to some reasonably small value like 5-30 seconds to allow
|
||||||
|
// at least some caching?
|
||||||
|
Ttl: 0,
|
||||||
|
},
|
||||||
|
A: net.ParseIP(ip.String()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNameserversFromResolvConf parses the nameservers from /etc/resolv.conf.
|
||||||
|
func parseNameserversFromResolvConf() ([]netip.Addr, error) {
|
||||||
|
config, err := dns.ClientConfigFromFile("/etc/resolv.conf")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var servers []netip.Addr
|
||||||
|
for _, server := range config.Servers {
|
||||||
|
if addr, err := netip.ParseAddr(server); err == nil {
|
||||||
|
servers = append(servers, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimInternalDomain(name string) string {
|
||||||
|
name = dns.CanonicalName(name)
|
||||||
|
if !dns.IsSubDomain(InternalDomain, name) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSuffix(name, "."+InternalDomain)
|
||||||
|
}
|
||||||
@@ -4,19 +4,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
dockercontainer "github.com/docker/docker/api/types/container"
|
dockercontainer "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/events"
|
"github.com/docker/docker/api/types/events"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"log/slog"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NetworkName = "uncloud"
|
NetworkName = "uncloud"
|
||||||
UserChain = "DOCKER-USER"
|
|
||||||
// EventsDebounceInterval defines how long to wait before processing the next Docker event. Multiple events
|
// EventsDebounceInterval defines how long to wait before processing the next Docker event. Multiple events
|
||||||
// occurring within this window will be processed together as a single event to prevent system overload.
|
// occurring within this window will be processed together as a single event to prevent system overload.
|
||||||
EventsDebounceInterval = 100 * time.Millisecond
|
EventsDebounceInterval = 100 * time.Millisecond
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnsureUncloudNetwork is a stub for darwin.
|
// EnsureUncloudNetwork is a stub for Darwin.
|
||||||
func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) error {
|
func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix, dnsServer netip.Addr) error {
|
||||||
return fmt.Errorf("not supported on darwin")
|
return fmt.Errorf("not supported on Darwin")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,28 @@ package docker
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
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/client"
|
||||||
"github.com/docker/docker/libnetwork/iptables"
|
"github.com/docker/docker/libnetwork/iptables"
|
||||||
"log/slog"
|
"github.com/psviderski/uncloud/internal/machine/dns"
|
||||||
"net/netip"
|
"github.com/psviderski/uncloud/internal/machine/firewall"
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet
|
// EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet
|
||||||
// if it doesn't exist. If the network exists but has a different subnet, it removes and recreates the network.
|
// if it doesn't exist. If the network exists but has a different subnet, it removes and recreates the network.
|
||||||
// It also configures iptables to allow container access from the WireGuard network.
|
// It also configures iptables to allow container access from the WireGuard network.
|
||||||
func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) error {
|
func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix, dnsServer netip.Addr) error {
|
||||||
// Ensure the Docker network 'uncloud' is created with the correct subnet.
|
// Ensure the Docker network 'uncloud' is created with the correct subnet.
|
||||||
needsCreation := false
|
needsCreation := false
|
||||||
nw, err := m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
nw, err := m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !client.IsErrNotFound(err) {
|
if !client.IsErrNotFound(err) {
|
||||||
return fmt.Errorf("inspect Docker network %q: %w", NetworkName, err)
|
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
||||||
}
|
}
|
||||||
needsCreation = true
|
needsCreation = true
|
||||||
} else if nw.IPAM.Config[0].Subnet != subnet.String() {
|
} else if nw.IPAM.Config[0].Subnet != subnet.String() {
|
||||||
@@ -31,7 +35,7 @@ func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix)
|
|||||||
)
|
)
|
||||||
if err = m.client.NetworkRemove(ctx, NetworkName); err != nil {
|
if err = m.client.NetworkRemove(ctx, NetworkName); err != nil {
|
||||||
// It can still fail if the network is in use by a container. Leave it to the user to resolve the issue.
|
// It can still fail if the network is in use by a container. Leave it to the user to resolve the issue.
|
||||||
return fmt.Errorf("remove Docker network %q: %w", NetworkName, err)
|
return fmt.Errorf("remove Docker network '%s': %w", NetworkName, err)
|
||||||
}
|
}
|
||||||
needsCreation = true
|
needsCreation = true
|
||||||
}
|
}
|
||||||
@@ -50,12 +54,12 @@ func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix)
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("create Docker network %q: %w", NetworkName, err)
|
return fmt.Errorf("create Docker network '%s': %w", NetworkName, err)
|
||||||
}
|
}
|
||||||
slog.Info("Docker network created.", "name", NetworkName, "subnet", subnet.String())
|
slog.Info("Docker network created.", "name", NetworkName, "subnet", subnet.String())
|
||||||
|
|
||||||
if nw, err = m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}); err != nil {
|
if nw, err = m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}); err != nil {
|
||||||
return fmt.Errorf("inspect Docker network %q: %w", NetworkName, err)
|
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +71,36 @@ func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix)
|
|||||||
// Bridge name doesn't seem to be documented but this is the source code where it is generated:
|
// Bridge name doesn't seem to be documented but this is the source code where it is generated:
|
||||||
// https://github.com/moby/moby/blob/v27.2.1/libnetwork/drivers/bridge/bridge_linux.go#L664
|
// https://github.com/moby/moby/blob/v27.2.1/libnetwork/drivers/bridge/bridge_linux.go#L664
|
||||||
bridgeName := "br-" + nw.ID[:12]
|
bridgeName := "br-" + nw.ID[:12]
|
||||||
|
|
||||||
|
if err = configureIptables(bridgeName, dnsServer); err != nil {
|
||||||
|
return fmt.Errorf("configure iptables for Docker network '%s': %w", NetworkName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureIptables configures iptables rules for the uncloud Docker network.
|
||||||
|
func configureIptables(bridgeName string, dnsServer netip.Addr) error {
|
||||||
ipt := iptables.GetIptable(iptables.IPv4)
|
ipt := iptables.GetIptable(iptables.IPv4)
|
||||||
rule := []string{"--in-interface", network.WireGuardInterfaceName, "--out-interface", bridgeName, "-j", "ACCEPT"}
|
// Allow traffic from other machines and their containers through the WG mesh to the Uncloud containers
|
||||||
if err = ipt.ProgramRule(iptables.Filter, UserChain, iptables.Insert, rule); err != nil {
|
// on the machine.
|
||||||
|
wgRule := []string{"--in-interface", network.WireGuardInterfaceName, "--out-interface", bridgeName, "-j", "ACCEPT"}
|
||||||
|
if err := ipt.ProgramRule(iptables.Filter, firewall.DockerUserChain, iptables.Insert, wgRule); err != nil {
|
||||||
return fmt.Errorf("insert iptables rule: %w", err)
|
return fmt.Errorf("insert iptables rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow DNS queries from Uncloud containers to the embedded DNS server.
|
||||||
|
for _, proto := range []string{"udp", "tcp"} {
|
||||||
|
dnsRule := []string{
|
||||||
|
"--in-interface", bridgeName,
|
||||||
|
"--dst", dnsServer.String(),
|
||||||
|
"--protocol", proto,
|
||||||
|
"--dport", strconv.Itoa(dns.Port),
|
||||||
|
"-j", "ACCEPT",
|
||||||
|
}
|
||||||
|
if err := ipt.ProgramRule(iptables.Filter, firewall.UncloudInputChain, iptables.Insert, dnsRule); err != nil {
|
||||||
|
return fmt.Errorf("insert iptables rule: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -30,6 +31,7 @@ import (
|
|||||||
"github.com/opencontainers/go-digest"
|
"github.com/opencontainers/go-digest"
|
||||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/dns"
|
||||||
"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"
|
"google.golang.org/grpc"
|
||||||
@@ -45,13 +47,17 @@ type Server struct {
|
|||||||
pb.UnimplementedDockerServer
|
pb.UnimplementedDockerServer
|
||||||
client *client.Client
|
client *client.Client
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
||||||
|
// address if the address is unknown (e.g. when the machine is not initialised yet).
|
||||||
|
internalDNSIP func() netip.Addr
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new Docker gRPC server with the provided Docker client.
|
// NewServer creates a new Docker gRPC server with the provided Docker client.
|
||||||
func NewServer(cli *client.Client, db *sqlx.DB) *Server {
|
func NewServer(cli *client.Client, db *sqlx.DB, internalDNSIP func() netip.Addr) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
client: cli,
|
client: cli,
|
||||||
db: db,
|
db: db,
|
||||||
|
internalDNSIP: internalDNSIP,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +345,12 @@ func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest)
|
|||||||
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always add the managed label to the volume to indicate that it is managed by Uncloud.
|
||||||
|
if opts.Labels == nil {
|
||||||
|
opts.Labels = make(map[string]string)
|
||||||
|
}
|
||||||
|
opts.Labels[api.LabelManaged] = ""
|
||||||
|
|
||||||
vol, err := s.client.VolumeCreate(ctx, opts)
|
vol, err := s.client.VolumeCreate(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -505,6 +517,16 @@ func (s *Server) CreateServiceContainer(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure the container to use the internal DNS server if it's available.
|
||||||
|
dnsIP := s.internalDNSIP()
|
||||||
|
if dnsIP.IsValid() {
|
||||||
|
hostConfig.DNS = []string{dnsIP.String()}
|
||||||
|
// Optimize DNS resolution for service discovery by appending the search domain to names without a dot.
|
||||||
|
// For example, the first attempt for "my-service" will be "my-service.internal".
|
||||||
|
hostConfig.DNSOptions = []string{"ndots:1"}
|
||||||
|
hostConfig.DNSSearch = []string{dns.InternalDomain}
|
||||||
|
}
|
||||||
|
|
||||||
if spec.Container.LogDriver != nil {
|
if spec.Container.LogDriver != nil {
|
||||||
hostConfig.LogConfig = container.LogConfig{
|
hostConfig.LogConfig = container.LogConfig{
|
||||||
Type: spec.Container.LogDriver.Name,
|
Type: spec.Container.LogDriver.Name,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package firewall
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// ConfigureIptablesChains is a stub for Darwin.
|
||||||
|
func ConfigureIptablesChains() error {
|
||||||
|
return fmt.Errorf("not supported on Darwin")
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/docker/docker/libnetwork/iptables"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DockerUserChain = "DOCKER-USER"
|
||||||
|
UncloudInputChain = "UNCLOUD-INPUT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigureIptablesChains sets up custom iptables chains and initial firewall rules for Uncloud networking.
|
||||||
|
func ConfigureIptablesChains() error {
|
||||||
|
// Ensure iptables UNCLOUD-INPUT chain with a RETURN rule exists. All existing rules are flushed.
|
||||||
|
ipt := iptables.GetIptable(iptables.IPv4)
|
||||||
|
if _, err := ipt.NewChain(UncloudInputChain, iptables.Filter); err != nil {
|
||||||
|
return fmt.Errorf("create iptables chain '%s': %w", UncloudInputChain, err)
|
||||||
|
}
|
||||||
|
if err := ipt.RawCombinedOutput("-t", string(iptables.Filter), "-F", UncloudInputChain); err != nil {
|
||||||
|
return fmt.Errorf("flush iptables chain '%s': %w", UncloudInputChain, err)
|
||||||
|
}
|
||||||
|
if err := ipt.AddReturnRule(UncloudInputChain); err != nil {
|
||||||
|
return fmt.Errorf("add the RETURN rule for iptables chain '%s': %w", UncloudInputChain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the main iptables INPUT chain has a jump rule to the UNCLOUD-INPUT chain before any DROP/REJECT rules.
|
||||||
|
jumpRule := []string{"-m", "comment", "--comment", "Uncloud-managed", "-j", UncloudInputChain}
|
||||||
|
if !ipt.Exists(iptables.Filter, "INPUT", jumpRule...) {
|
||||||
|
// Look for the first DROP/REJECT rule in the INPUT chain.
|
||||||
|
out, err := ipt.Raw("-t", string(iptables.Filter), "-L", "INPUT", "--line-numbers")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get iptables rules for chain '%s': %w", UncloudInputChain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstRejectRuleNum := 0
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fields[1] == "DROP" || fields[1] == "REJECT" {
|
||||||
|
if ruleNum, err := strconv.Atoi(fields[0]); err == nil {
|
||||||
|
firstRejectRuleNum = ruleNum
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var addJumpRule []string
|
||||||
|
if firstRejectRuleNum > 0 {
|
||||||
|
addJumpRule = append([]string{"-t", string(iptables.Filter), "-I", "INPUT", strconv.Itoa(firstRejectRuleNum)},
|
||||||
|
jumpRule...)
|
||||||
|
} else {
|
||||||
|
addJumpRule = append([]string{"-t", string(iptables.Filter), "-A", "INPUT"}, jumpRule...)
|
||||||
|
}
|
||||||
|
if err = ipt.RawCombinedOutput(addJumpRule...); err != nil {
|
||||||
|
return fmt.Errorf("add iptables rule '%s': %w", strings.Join(addJumpRule, " "), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow WireGuard traffic to the machine.
|
||||||
|
acceptWireGuardRule := []string{"-p", "udp", "--dport", strconv.Itoa(network.WireGuardPort), "-j", "ACCEPT"}
|
||||||
|
err := ipt.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, acceptWireGuardRule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(acceptWireGuardRule, " "), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -21,9 +21,10 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
apiproxy "github.com/psviderski/uncloud/internal/machine/api/proxy"
|
apiproxy "github.com/psviderski/uncloud/internal/machine/api/proxy"
|
||||||
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
"github.com/psviderski/uncloud/internal/machine/caddyconfig"
|
||||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
"github.com/psviderski/uncloud/internal/machine/corroservice"
|
"github.com/psviderski/uncloud/internal/machine/corroservice"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/dns"
|
||||||
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"
|
||||||
@@ -62,6 +63,8 @@ type Config struct {
|
|||||||
// CaddyConfigPath specifies where the machine generates the Caddy reverse proxy configuration file for routing
|
// CaddyConfigPath specifies 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/caddy.json.
|
// external traffic to service containers across the internal network. Default is DataDir/caddy/caddy.json.
|
||||||
CaddyConfigPath string
|
CaddyConfigPath string
|
||||||
|
// DNSUpstreams specifies the upstream DNS servers for the embedded internal DNS server.
|
||||||
|
DNSUpstreams []netip.AddrPort
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDefaults returns a new Config with default values set where not provided.
|
// SetDefaults returns a new Config with default values set where not provided.
|
||||||
@@ -206,7 +209,7 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
}
|
}
|
||||||
c := cluster.NewCluster(corroStore, corroAdmin)
|
c := cluster.NewCluster(corroStore, corroAdmin)
|
||||||
|
|
||||||
// Init a gRPC Docker server that proxies requests to the local Docker daemon.
|
// Init dependencies for a gRPC Docker server that proxies requests to the local Docker daemon.
|
||||||
dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create Docker client: %w", err)
|
return nil, fmt.Errorf("create Docker client: %w", err)
|
||||||
@@ -217,7 +220,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)
|
||||||
}
|
}
|
||||||
dockerServer := machinedocker.NewServer(dockerCli, 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.
|
||||||
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, APIPort)
|
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, APIPort)
|
||||||
@@ -235,11 +237,16 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
initialised: make(chan struct{}, 1),
|
initialised: make(chan struct{}, 1),
|
||||||
store: corroStore,
|
store: corroStore,
|
||||||
cluster: c,
|
cluster: c,
|
||||||
docker: dockerServer,
|
|
||||||
localProxyServer: localProxyServer,
|
localProxyServer: localProxyServer,
|
||||||
proxyDirector: proxyDirector,
|
proxyDirector: proxyDirector,
|
||||||
}
|
}
|
||||||
m.localMachineServer = newGRPCServer(m, c, dockerServer)
|
|
||||||
|
// Machine IP will only be available after the machine is initialised as a cluster member so wrap it in a function.
|
||||||
|
internalDNSIP := func() netip.Addr {
|
||||||
|
return m.IP()
|
||||||
|
}
|
||||||
|
m.docker = machinedocker.NewServer(dockerCli, db, internalDNSIP)
|
||||||
|
m.localMachineServer = newGRPCServer(m, c, m.docker)
|
||||||
|
|
||||||
if m.Initialised() {
|
if m.Initialised() {
|
||||||
m.initialised <- struct{}{}
|
m.initialised <- struct{}{}
|
||||||
@@ -270,6 +277,15 @@ func (m *Machine) Initialised() bool {
|
|||||||
return m.state.ID != ""
|
return m.state.ID != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IP returns the machine IPv4 address in the cluster network which is the first address in the machine subnet.
|
||||||
|
func (m *Machine) IP() netip.Addr {
|
||||||
|
if !m.Initialised() {
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return network.MachineIP(m.state.Network.Subnet)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Machine) Run(ctx context.Context) error {
|
func (m *Machine) Run(ctx context.Context) error {
|
||||||
// Docker dependency is essential for the machine to function. Block until it's ready.
|
// Docker dependency is essential for the machine to function. Block until it's ready.
|
||||||
if err := docker.WaitDaemonReady(ctx, m.config.DockerClient); err != nil {
|
if err := docker.WaitDaemonReady(ctx, m.config.DockerClient); err != nil {
|
||||||
@@ -368,11 +384,17 @@ func (m *Machine) Run(ctx context.Context) error {
|
|||||||
|
|
||||||
// Create a new Caddyfile controller for managing the Caddy reverse proxy configuration.
|
// Create a new Caddyfile controller for managing the Caddy reverse proxy configuration.
|
||||||
// It will also serve the current machine ID at /.uncloud-verify to verify Caddy reachability.
|
// It will also serve the current machine ID at /.uncloud-verify to verify Caddy reachability.
|
||||||
caddyfileCtrl, err := caddyfile.NewController(m.store, m.config.CaddyConfigPath, m.state.ID)
|
caddyfileCtrl, err := caddyconfig.NewController(m.store, m.config.CaddyConfigPath, m.state.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create Caddyfile controller: %w", err)
|
return fmt.Errorf("create Caddyfile controller: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dnsResolver := dns.NewClusterResolver(m.store)
|
||||||
|
dnsServer, err := dns.NewServer(m.IP(), dnsResolver, m.config.DNSUpstreams)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create embedded DNS server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctrl, err = newNetworkController(
|
ctrl, err = newNetworkController(
|
||||||
m.state,
|
m.state,
|
||||||
m.store,
|
m.store,
|
||||||
@@ -380,6 +402,8 @@ func (m *Machine) Run(ctx context.Context) error {
|
|||||||
m.config.CorrosionService,
|
m.config.CorrosionService,
|
||||||
m.config.DockerClient,
|
m.config.DockerClient,
|
||||||
caddyfileCtrl,
|
caddyfileCtrl,
|
||||||
|
dnsServer,
|
||||||
|
dnsResolver,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialise network controller: %w", err)
|
return fmt.Errorf("initialise network controller: %w", err)
|
||||||
@@ -520,6 +544,38 @@ func (m *Machine) configureCorrosion() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckPrerequisites verifies if the machine meets all necessary system requirements to participate in the cluster.
|
||||||
|
func (m *Machine) CheckPrerequisites(ctx context.Context, _ *emptypb.Empty) (*pb.CheckPrerequisitesResponse, error) {
|
||||||
|
// Check DNS port (UDP) availability.
|
||||||
|
if err := checkDNSPortAvailable(); err != nil {
|
||||||
|
return &pb.CheckPrerequisitesResponse{
|
||||||
|
Satisfied: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.CheckPrerequisitesResponse{
|
||||||
|
Satisfied: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDNSPortAvailable verifies that DNS port 53/udp is available for Uncloud's embedded DNS service.
|
||||||
|
func checkDNSPortAvailable() error {
|
||||||
|
addr := &net.UDPAddr{
|
||||||
|
IP: net.IPv4(127, 0, 0, 210), // Use a unique loopback address to avoid conflicts.
|
||||||
|
Port: dns.Port,
|
||||||
|
}
|
||||||
|
conn, err := net.ListenUDP("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("DNS port %d/udp is already in use by another service: %w. Uncloud needs this port "+
|
||||||
|
"to run the embedded internal DNS service on WireGuard interface 'uncloud'. Please reconfigure "+
|
||||||
|
"any DNS servers (like dnsmasq, systemd-resolved, or named) that might be listening on all network "+
|
||||||
|
"interfaces (0.0.0.0) on the machine and try again", dns.Port, err)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// InitCluster initialises a new cluster on the local machine with the provided network configuration.
|
// InitCluster initialises a new cluster on the local machine with the provided network configuration.
|
||||||
func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (*pb.InitClusterResponse, error) {
|
func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (*pb.InitClusterResponse, error) {
|
||||||
if m.Initialised() {
|
if m.Initialised() {
|
||||||
@@ -720,6 +776,22 @@ func (m *Machine) Inspect(_ context.Context, _ *emptypb.Empty) (*pb.MachineInfo,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset restores the machine to a clean state, removing all cluster-related сonfiguration and data and scheduling
|
||||||
|
// a graceful shutdown. The uncloud daemon will restart the machine if managed by systemd.
|
||||||
|
func (m *Machine) Reset(ctx context.Context, _ *pb.ResetRequest) (*emptypb.Empty, error) {
|
||||||
|
slog.Info("Resetting machine to a clean state.")
|
||||||
|
|
||||||
|
// TODO: stop and remove all managed service containers.
|
||||||
|
// TODO: check if the request is coming from the unix or network socket. For the network socket, the reset should
|
||||||
|
// be called in a separate goroutine to avoid blocking the RPC response.
|
||||||
|
// TODO: stop the network controller
|
||||||
|
// TODO: implement and call Cleanup on the network controller to remove Docker network, WG interface, iptables
|
||||||
|
// rules, corrosion state, ?stop corrosion service.
|
||||||
|
// TODO: stop the machine and remove the machine.json state. The daemon should restart it to a clean state.
|
||||||
|
|
||||||
|
return &emptypb.Empty{}, status.Error(codes.Unimplemented, "reset machine is not implemented yet")
|
||||||
|
}
|
||||||
|
|
||||||
// InspectService returns detailed information about a service and its containers stored in the cluster store.
|
// InspectService returns detailed information about a service and its containers stored in the cluster store.
|
||||||
func (m *Machine) InspectService(
|
func (m *Machine) InspectService(
|
||||||
ctx context.Context, req *pb.InspectServiceRequest,
|
ctx context.Context, req *pb.InspectServiceRequest,
|
||||||
|
|||||||
+89
-71
@@ -4,22 +4,25 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/cenkalti/backoff/v4"
|
|
||||||
"github.com/docker/docker/client"
|
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"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/internal/machine/caddyfile"
|
"github.com/psviderski/uncloud/internal/machine/caddyconfig"
|
||||||
"github.com/psviderski/uncloud/internal/machine/corroservice"
|
"github.com/psviderski/uncloud/internal/machine/corroservice"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/dns"
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"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"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -36,10 +39,11 @@ type networkController struct {
|
|||||||
server *grpc.Server
|
server *grpc.Server
|
||||||
corroService corroservice.Service
|
corroService corroservice.Service
|
||||||
dockerCli *client.Client
|
dockerCli *client.Client
|
||||||
caddyfileCtrl *caddyfile.Controller
|
caddyfileCtrl *caddyconfig.Controller
|
||||||
|
|
||||||
// TODO: DNS server/resolver listening on the machine IP, e.g. 10.210.0.1:53. It can't listen on 127.0.X.X
|
// dnsServer is the embedded internal DNS server for the cluster listening on the machine IP.
|
||||||
// like resolved does because it needs to be reachable from both the host and the containers.
|
dnsServer *dns.Server
|
||||||
|
dnsResolver *dns.ClusterResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
func newNetworkController(
|
func newNetworkController(
|
||||||
@@ -48,7 +52,9 @@ func newNetworkController(
|
|||||||
server *grpc.Server,
|
server *grpc.Server,
|
||||||
corroService corroservice.Service,
|
corroService corroservice.Service,
|
||||||
dockerCli *client.Client,
|
dockerCli *client.Client,
|
||||||
caddyfileCtrl *caddyfile.Controller,
|
caddyfileCtrl *caddyconfig.Controller,
|
||||||
|
dnsServer *dns.Server,
|
||||||
|
dnsResolver *dns.ClusterResolver,
|
||||||
) (
|
) (
|
||||||
*networkController, error,
|
*networkController, error,
|
||||||
) {
|
) {
|
||||||
@@ -68,10 +74,16 @@ func newNetworkController(
|
|||||||
corroService: corroService,
|
corroService: corroService,
|
||||||
dockerCli: dockerCli,
|
dockerCli: dockerCli,
|
||||||
caddyfileCtrl: caddyfileCtrl,
|
caddyfileCtrl: caddyfileCtrl,
|
||||||
|
dnsServer: dnsServer,
|
||||||
|
dnsResolver: dnsResolver,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (nc *networkController) Run(ctx context.Context) error {
|
func (nc *networkController) Run(ctx context.Context) error {
|
||||||
|
if err := firewall.ConfigureIptablesChains(); err != nil {
|
||||||
|
return fmt.Errorf("configure iptables chains: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := nc.wgnet.Configure(*nc.state.Network); err != nil {
|
if err := nc.wgnet.Configure(*nc.state.Network); err != nil {
|
||||||
return fmt.Errorf("configure WireGuard network: %w", err)
|
return fmt.Errorf("configure WireGuard network: %w", err)
|
||||||
}
|
}
|
||||||
@@ -110,86 +122,92 @@ func (nc *networkController) Run(ctx context.Context) error {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
errGroup.Go(func() error {
|
||||||
|
slog.Info("Starting embedded DNS resolver.")
|
||||||
|
if err := nc.dnsResolver.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("embedded DNS resolver failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
errGroup.Go(func() error {
|
||||||
|
slog.Info("Starting embedded DNS server.")
|
||||||
|
if err := nc.dnsServer.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("embedded DNS server failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
// Setup Docker network and synchronise containers to the cluster store.
|
// Setup Docker network and synchronise containers to the cluster store.
|
||||||
errGroup.Go(
|
errGroup.Go(func() error {
|
||||||
func() error {
|
return nc.prepareAndWatchDocker(ctx)
|
||||||
return nc.prepareAndWatchDocker(ctx)
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handle machine changes in the cluster. Handling machine and endpoint changes should be done
|
// Handle machine changes in the cluster. Handling machine and endpoint changes should be done
|
||||||
// in separate goroutines to avoid a deadlock when reconfiguring the network.
|
// in separate goroutines to avoid a deadlock when reconfiguring the network.
|
||||||
errGroup.Go(
|
errGroup.Go(func() error {
|
||||||
func() error {
|
if err := nc.handleMachineChanges(ctx); err != nil {
|
||||||
if err := nc.handleMachineChanges(ctx); err != nil {
|
return fmt.Errorf("handle new machines: %w", err)
|
||||||
return fmt.Errorf("handle new machines: %w", err)
|
}
|
||||||
}
|
return nil
|
||||||
return nil
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Watch for endpoint changes and update the machine state accordingly.
|
// Watch for endpoint changes and update the machine state accordingly.
|
||||||
errGroup.Go(
|
errGroup.Go(func() error {
|
||||||
func() error {
|
for {
|
||||||
for {
|
select {
|
||||||
select {
|
case e, ok := <-nc.endpointChanges:
|
||||||
case e, ok := <-nc.endpointChanges:
|
if !ok {
|
||||||
if !ok {
|
// The channel was closed, stop watching for changes.
|
||||||
// The channel was closed, stop watching for changes.
|
nc.endpointChanges = nil
|
||||||
nc.endpointChanges = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
nc.state.mu.Lock()
|
|
||||||
for i := range nc.state.Network.Peers {
|
|
||||||
if nc.state.Network.Peers[i].PublicKey.Equal(e.PublicKey) {
|
|
||||||
nc.state.Network.Peers[i].Endpoint = &e.Endpoint
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := nc.state.Save(); err != nil {
|
|
||||||
slog.Error("Failed to save machine state.", "err", err)
|
|
||||||
}
|
|
||||||
nc.state.mu.Unlock()
|
|
||||||
|
|
||||||
slog.Debug("Preserved endpoint change in the machine state.",
|
|
||||||
"public_key", e.PublicKey, "endpoint", e.Endpoint)
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
errGroup.Go(
|
nc.state.mu.Lock()
|
||||||
func() error {
|
for i := range nc.state.Network.Peers {
|
||||||
if err := nc.wgnet.Run(ctx); err != nil {
|
if nc.state.Network.Peers[i].PublicKey.Equal(e.PublicKey) {
|
||||||
return fmt.Errorf("WireGuard network failed: %w", err)
|
nc.state.Network.Peers[i].Endpoint = &e.Endpoint
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := nc.state.Save(); err != nil {
|
||||||
|
slog.Error("Failed to save machine state.", "err", err)
|
||||||
|
}
|
||||||
|
nc.state.mu.Unlock()
|
||||||
|
|
||||||
|
slog.Debug("Preserved endpoint change in the machine state.",
|
||||||
|
"public_key", e.PublicKey, "endpoint", e.Endpoint)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
errGroup.Go(func() error {
|
errGroup.Go(func() error {
|
||||||
slog.Info("Starting Caddyfile controller.")
|
if err := nc.wgnet.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("WireGuard network failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
errGroup.Go(func() error {
|
||||||
|
slog.Info("Starting Caddyconfig controller.")
|
||||||
if err := nc.caddyfileCtrl.Run(ctx); err != nil {
|
if err := nc.caddyfileCtrl.Run(ctx); err != nil {
|
||||||
//goland:noinspection GoErrorStringFormat
|
//goland:noinspection GoErrorStringFormat
|
||||||
return fmt.Errorf("Caddyfile controller failed: %w", err)
|
return fmt.Errorf("Caddyconfig controller failed: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
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.
|
||||||
errGroup.Go(
|
errGroup.Go(func() error {
|
||||||
func() error {
|
<-ctx.Done()
|
||||||
<-ctx.Done()
|
slog.Info("Stopping network API server.")
|
||||||
slog.Info("Stopping network API server.")
|
// TODO: implement timeout for graceful shutdown.
|
||||||
// TODO: implement timeout for graceful shutdown.
|
nc.server.GracefulStop()
|
||||||
nc.server.GracefulStop()
|
slog.Info("Network API server stopped.")
|
||||||
slog.Info("Network API server stopped.")
|
return nil
|
||||||
return nil
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return errGroup.Wait()
|
return errGroup.Wait()
|
||||||
}
|
}
|
||||||
@@ -202,7 +220,7 @@ func (nc *networkController) prepareAndWatchDocker(ctx context.Context) error {
|
|||||||
return fmt.Errorf("wait for Docker daemon: %w", err)
|
return fmt.Errorf("wait for Docker daemon: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := manager.EnsureUncloudNetwork(ctx, nc.state.Network.Subnet); err != nil {
|
if err := manager.EnsureUncloudNetwork(ctx, nc.state.Network.Subnet, nc.dnsServer.ListenAddr()); err != nil {
|
||||||
return fmt.Errorf("ensure Docker network: %w", err)
|
return fmt.Errorf("ensure Docker network: %w", err)
|
||||||
}
|
}
|
||||||
slog.Info("Docker network configured.")
|
slog.Info("Docker network configured.")
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package network
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -13,7 +14,7 @@ type Config struct {
|
|||||||
// in the subnet. Other IP addresses are allocated to containers running on the machine.
|
// in the subnet. Other IP addresses are allocated to containers running on the machine.
|
||||||
Subnet netip.Prefix
|
Subnet netip.Prefix
|
||||||
// ManagementIP is the IPv6 address assigned to the machine within the WireGuard network. This address is used
|
// ManagementIP is the IPv6 address assigned to the machine within the WireGuard network. This address is used
|
||||||
// for cluster management traffic, such as gRPC communication with the machine API server and Serf gossip.
|
// for cluster management traffic, such as gRPC communication with the machine API server and Corrosion gossip.
|
||||||
ManagementIP netip.Addr
|
ManagementIP netip.Addr
|
||||||
PrivateKey secret.Secret
|
PrivateKey secret.Secret
|
||||||
PublicKey secret.Secret
|
PublicKey secret.Secret
|
||||||
@@ -23,7 +24,7 @@ type Config struct {
|
|||||||
type PeerConfig struct {
|
type PeerConfig struct {
|
||||||
Subnet *netip.Prefix `json:",omitempty"`
|
Subnet *netip.Prefix `json:",omitempty"`
|
||||||
// ManagementIP is the IPv6 address assigned to the peer within the WireGuard network. This address is used
|
// ManagementIP is the IPv6 address assigned to the peer within the WireGuard network. This address is used
|
||||||
// for cluster management traffic, such as gRPC communication with the machine API server and Serf gossip.
|
// for cluster management traffic, such as gRPC communication with the machine API server and Corrosion gossip.
|
||||||
ManagementIP netip.Addr
|
ManagementIP netip.Addr
|
||||||
Endpoint *netip.AddrPort `json:",omitempty"`
|
Endpoint *netip.AddrPort `json:",omitempty"`
|
||||||
AllEndpoints []netip.AddrPort `json:",omitempty"`
|
AllEndpoints []netip.AddrPort `json:",omitempty"`
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package network
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MachineIP returns the IP address of the machine which is the first address in the subnet.
|
// MachineIP returns the IP address of the machine which is the first address in the subnet.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/crypto/ssh/agent"
|
"golang.org/x/crypto/ssh/agent"
|
||||||
)
|
)
|
||||||
@@ -63,6 +64,7 @@ func sshAgentAuth() (ssh.AuthMethod, func(), error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func privateKeyAuth(path string) (ssh.AuthMethod, error) {
|
func privateKeyAuth(path string) (ssh.AuthMethod, error) {
|
||||||
|
path = fs.ExpandHomeDir(path)
|
||||||
key, err := os.ReadFile(path)
|
key, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read private key file %q: %w", path, err)
|
return nil, fmt.Errorf("read private key file %q: %w", path, err)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"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"
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"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"
|
||||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
@@ -30,6 +31,8 @@ type Cluster struct {
|
|||||||
|
|
||||||
type CreateClusterOptions struct {
|
type CreateClusterOptions struct {
|
||||||
Machines int
|
Machines int
|
||||||
|
// Ports to forward from the cluster machines to the host.
|
||||||
|
PortMap nat.PortMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cluster) PopulateMachineIDs(ctx context.Context) error {
|
func (c *Cluster) PopulateMachineIDs(ctx context.Context) error {
|
||||||
@@ -72,7 +75,7 @@ func (p *Provisioner) CreateCluster(ctx context.Context, name string, opts Creat
|
|||||||
ManagedLabel: "",
|
ManagedLabel: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Create a Docker network with the same as the cluster name.
|
// Create a Docker network with the same name as the cluster name.
|
||||||
if _, err = p.dockerCli.NetworkCreate(ctx, name, netOpts); err != nil {
|
if _, err = p.dockerCli.NetworkCreate(ctx, name, netOpts); err != nil {
|
||||||
return c, fmt.Errorf("create Docker network '%s': %w", name, err)
|
return c, fmt.Errorf("create Docker network '%s': %w", name, err)
|
||||||
}
|
}
|
||||||
@@ -81,7 +84,8 @@ func (p *Provisioner) CreateCluster(ctx context.Context, name string, opts Creat
|
|||||||
// Create machines (containers) in the created cluster network.
|
// Create machines (containers) in the created cluster network.
|
||||||
for i := 1; i < opts.Machines+1; i++ {
|
for i := 1; i < opts.Machines+1; i++ {
|
||||||
mopts := CreateMachineOptions{
|
mopts := CreateMachineOptions{
|
||||||
Name: fmt.Sprintf("machine-%d", i),
|
Name: fmt.Sprintf("machine-%d", i),
|
||||||
|
PortMap: opts.PortMap,
|
||||||
}
|
}
|
||||||
m, err := p.CreateMachine(ctx, name, mopts)
|
m, err := p.CreateMachine(ctx, name, mopts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ func (m *Machine) Connect(ctx context.Context) (*client.Client, error) {
|
|||||||
type CreateMachineOptions struct {
|
type CreateMachineOptions struct {
|
||||||
Name string
|
Name string
|
||||||
Image string
|
Image string
|
||||||
|
// Ports to forward from the machine to the host.
|
||||||
|
PortMap nat.PortMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provisioner) CreateMachine(ctx context.Context, clusterName string, opts CreateMachineOptions) (Machine, error) {
|
func (p *Provisioner) CreateMachine(ctx context.Context, clusterName string, opts CreateMachineOptions) (Machine, error) {
|
||||||
@@ -88,6 +90,16 @@ func (p *Provisioner) CreateMachine(ctx context.Context, clusterName string, opt
|
|||||||
Name: container.RestartPolicyAlways,
|
Name: container.RestartPolicyAlways,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
// Forward ports, if requested
|
||||||
|
if opts.PortMap != nil {
|
||||||
|
for port, bindings := range opts.PortMap {
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
config.ExposedPorts[port] = struct{}{}
|
||||||
|
hostConfig.PortBindings[port] = bindings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := p.createContainerWithImagePull(ctx, containerName, config, hostConfig); err != nil {
|
if _, err := p.createContainerWithImagePull(ctx, containerName, config, hostConfig); err != nil {
|
||||||
return m, err
|
return m, err
|
||||||
@@ -160,10 +172,7 @@ func (p *Provisioner) waitPortPublished(ctx context.Context, containerID string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding, ok := c.NetworkSettings.Ports[port]
|
binding, ok := c.NetworkSettings.Ports[port]
|
||||||
if !ok {
|
if ok && len(binding) > 0 {
|
||||||
return nil, fmt.Errorf("port '%s' not published", port)
|
|
||||||
}
|
|
||||||
if len(binding) > 0 {
|
|
||||||
return binding, nil
|
return binding, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +205,7 @@ func WaitMachineReady(ctx context.Context, m Machine, timeout time.Duration) err
|
|||||||
|
|
||||||
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||||
backoff.WithInitialInterval(100*time.Millisecond),
|
backoff.WithInitialInterval(100*time.Millisecond),
|
||||||
backoff.WithMaxInterval(1*time.Second),
|
backoff.WithMaxInterval(10*time.Second),
|
||||||
backoff.WithMaxElapsedTime(timeout),
|
backoff.WithMaxElapsedTime(timeout),
|
||||||
), ctx)
|
), ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
var version string
|
||||||
|
|
||||||
|
func String() string {
|
||||||
|
return version
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// DockerNetworkName is the name of the Docker network used by uncloud. Keep the value in sync with NetworkName
|
||||||
|
// in internal/machine/docker/manager.go.
|
||||||
|
DockerNetworkName = "uncloud"
|
||||||
LabelManaged = "uncloud.managed"
|
LabelManaged = "uncloud.managed"
|
||||||
LabelServiceID = "uncloud.service.id"
|
LabelServiceID = "uncloud.service.id"
|
||||||
LabelServiceName = "uncloud.service.name"
|
LabelServiceName = "uncloud.service.name"
|
||||||
@@ -90,6 +94,22 @@ func (c *Container) HumanState() (string, error) {
|
|||||||
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
|
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UncloudNetworkIP returns the IP address of the container in the uncloud Docker network.
|
||||||
|
func (c *Container) UncloudNetworkIP() netip.Addr {
|
||||||
|
network, ok := c.NetworkSettings.Networks[DockerNetworkName]
|
||||||
|
if !ok {
|
||||||
|
// Container is not connected to the uncloud Docker network (could be host network).
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
|
|
||||||
|
ip, err := netip.ParseAddr(network.IPAddress)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Container) UnmarshalJSON(data []byte) error {
|
func (c *Container) UnmarshalJSON(data []byte) error {
|
||||||
// A temporary type that's identical to Container but doesn't have the UnmarshalJSON method.
|
// A temporary type that's identical to Container but doesn't have the UnmarshalJSON method.
|
||||||
type ContainerAlias Container
|
type ContainerAlias Container
|
||||||
|
|||||||
+18
-2
@@ -30,7 +30,10 @@ const (
|
|||||||
PullPolicyNever = "never"
|
PullPolicyNever = "never"
|
||||||
)
|
)
|
||||||
|
|
||||||
var serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
var (
|
||||||
|
serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
||||||
|
dnsLabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
|
||||||
|
)
|
||||||
|
|
||||||
func ValidateServiceID(id string) bool {
|
func ValidateServiceID(id string) bool {
|
||||||
return serviceIDRegexp.MatchString(id)
|
return serviceIDRegexp.MatchString(id)
|
||||||
@@ -104,7 +107,14 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
return fmt.Errorf("invalid mode: %q", s.Mode)
|
return fmt.Errorf("invalid mode: %q", s.Mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: validate the service name is a valid DNS label.
|
if s.Name != "" {
|
||||||
|
if len(s.Name) > 63 {
|
||||||
|
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
|
||||||
|
}
|
||||||
|
if !dnsLabelRegexp.MatchString(s.Name) {
|
||||||
|
return fmt.Errorf("invalid service name: %q. must be 1-63 characters, lowercase letters, numbers, and dashes only; must start and end with a letter or number", s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, p := range s.Ports {
|
for _, p := range s.Ports {
|
||||||
if (p.Mode == "" || p.Mode == PortModeIngress) &&
|
if (p.Mode == "" || p.Mode == PortModeIngress) &&
|
||||||
@@ -191,6 +201,12 @@ type ContainerSpec struct {
|
|||||||
// SetDefaults returns a copy of the container spec with default values set.
|
// SetDefaults returns a copy of the container spec with default values set.
|
||||||
func (s *ContainerSpec) SetDefaults() ContainerSpec {
|
func (s *ContainerSpec) SetDefaults() ContainerSpec {
|
||||||
spec := s.Clone()
|
spec := s.Clone()
|
||||||
|
if spec.LogDriver == nil {
|
||||||
|
spec.LogDriver = &LogDriver{
|
||||||
|
Name: "local",
|
||||||
|
Options: map[string]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
if spec.PullPolicy == "" {
|
if spec.PullPolicy == "" {
|
||||||
spec.PullPolicy = PullPolicyMissing
|
spec.PullPolicy = PullPolicyMissing
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -24,12 +24,12 @@ var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
|||||||
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
||||||
// version of the official Caddy Docker image is used.
|
// version of the official Caddy Docker image is used.
|
||||||
func (cli *Client) NewCaddyDeployment(image string, placement api.Placement) (*deploy.Deployment, error) {
|
func (cli *Client) NewCaddyDeployment(image string, placement api.Placement) (*deploy.Deployment, error) {
|
||||||
latest, err := LatestCaddyImage()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if image == "" {
|
if image == "" {
|
||||||
|
latest, err := LatestCaddyImage()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
image = reference.FamiliarString(latest)
|
image = reference.FamiliarString(latest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import (
|
|||||||
// FakeProjectName is a placeholder name for the project to be able to strip it from the resource names used as prefix.
|
// FakeProjectName is a placeholder name for the project to be able to strip it from the resource names used as prefix.
|
||||||
const FakeProjectName = "f-a-k-e"
|
const FakeProjectName = "f-a-k-e"
|
||||||
|
|
||||||
func LoadProject(ctx context.Context, paths []string) (*types.Project, error) {
|
func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) {
|
||||||
options, err := composecli.NewProjectOptions(
|
defaultOpts := []composecli.ProjectOptionsFn{
|
||||||
paths,
|
|
||||||
composecli.WithName(FakeProjectName),
|
composecli.WithName(FakeProjectName),
|
||||||
// First apply os.Environment, always wins.
|
// First apply os.Environment, always wins.
|
||||||
composecli.WithOsEnv,
|
composecli.WithOsEnv,
|
||||||
@@ -23,9 +22,14 @@ func LoadProject(ctx context.Context, paths []string) (*types.Project, error) {
|
|||||||
composecli.WithDotEnv,
|
composecli.WithDotEnv,
|
||||||
// Get compose file path set by COMPOSE_FILE.
|
// Get compose file path set by COMPOSE_FILE.
|
||||||
composecli.WithConfigFileEnv,
|
composecli.WithConfigFileEnv,
|
||||||
// If none was selected, get default compose-ports-long.yaml file from current dir or parent folders.
|
// If none was selected, get default Compose file names from current or parent folders.
|
||||||
composecli.WithDefaultConfigPath,
|
composecli.WithDefaultConfigPath,
|
||||||
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
options, err := composecli.NewProjectOptions(
|
||||||
|
paths,
|
||||||
|
append(defaultOpts, opts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create compose parser options: %w", err)
|
return nil, fmt.Errorf("create compose parser options: %w", err)
|
||||||
|
|||||||
@@ -95,7 +95,10 @@ func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) erro
|
|||||||
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
|
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
|
||||||
return fmt.Errorf("stop container: %w", err)
|
return fmt.Errorf("stop container: %w", err)
|
||||||
}
|
}
|
||||||
if err := cli.RemoveContainer(ctx, o.ServiceID, o.ContainerID, container.RemoveOptions{}); err != nil {
|
if err := cli.RemoveContainer(ctx, o.ServiceID, o.ContainerID, container.RemoveOptions{
|
||||||
|
// Remove anonymous volumes created by the container.
|
||||||
|
RemoveVolumes: true,
|
||||||
|
}); err != nil {
|
||||||
return fmt.Errorf("remove container: %w", err)
|
return fmt.Errorf("remove container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ import (
|
|||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
"github.com/psviderski/uncloud/internal/machine/caddyconfig"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -110,7 +110,7 @@ func verifyCaddyReachable(ctx context.Context, m *pb.MachineInfo) error {
|
|||||||
eventID := fmt.Sprintf("Machine %s (%s)", m.Name, publicIP)
|
eventID := fmt.Sprintf("Machine %s (%s)", m.Name, publicIP)
|
||||||
pw.Event(progress.NewEvent(eventID, progress.Working, "Querying"))
|
pw.Event(progress.NewEvent(eventID, progress.Working, "Querying"))
|
||||||
|
|
||||||
verifyURL := fmt.Sprintf("http://%s%s", publicIP, caddyfile.VerifyPath)
|
verifyURL := fmt.Sprintf("http://%s%s", publicIP, caddyconfig.VerifyPath)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -251,7 +251,10 @@ func (cli *Client) RemoveService(ctx context.Context, id string) error {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = cli.RemoveContainer(ctx, svc.ID, mc.Container.ID, container.RemoveOptions{})
|
err = cli.RemoveContainer(ctx, svc.ID, mc.Container.ID, container.RemoveOptions{
|
||||||
|
// Remove anonymous volumes created by the container.
|
||||||
|
RemoveVolumes: true,
|
||||||
|
})
|
||||||
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
errCh <- fmt.Errorf("remove container '%s': %w", mc.Container.ID, err)
|
errCh <- fmt.Errorf("remove container '%s': %w", mc.Container.ID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ cleanup() {
|
|||||||
echo "Terminating container processes..."
|
echo "Terminating container processes..."
|
||||||
|
|
||||||
# Terminate the main process if it has been started.
|
# Terminate the main process if it has been started.
|
||||||
if [ -n "$MAIN_PID" ]; then
|
if [ -n "${MAIN_PID:-}" ]; then
|
||||||
kill "$MAIN_PID" 2>/dev/null || true
|
kill "$MAIN_PID" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api
|
|||||||
assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init)
|
assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init)
|
||||||
assert.True(t, strings.HasPrefix(ctr.Name, spec.Name+"-"))
|
assert.True(t, strings.HasPrefix(ctr.Name, spec.Name+"-"))
|
||||||
|
|
||||||
// If LogDriver is not set, any log driver set as default in the Docker daemon config could be used.
|
|
||||||
if spec.Container.LogDriver != nil {
|
if spec.Container.LogDriver != nil {
|
||||||
assert.Equal(t, spec.Container.LogDriver.Name, ctr.HostConfig.LogConfig.Type)
|
assert.Equal(t, spec.Container.LogDriver.Name, ctr.HostConfig.LogConfig.Type)
|
||||||
assert.Equal(t, spec.Container.LogDriver.Options, ctr.HostConfig.LogConfig.Config)
|
assert.Equal(t, spec.Container.LogDriver.Options, ctr.HostConfig.LogConfig.Config)
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
cliInternal "github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"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 TestComposeBuild(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
clusterName := "ucind-test.compose-build"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
registryInternalPort := "5000/tcp"
|
||||||
|
clusterOpts := ucind.CreateClusterOptions{
|
||||||
|
Machines: 1,
|
||||||
|
PortMap: nat.PortMap{
|
||||||
|
// Forwarding the registry port to the host.
|
||||||
|
nat.Port(registryInternalPort): []nat.PortBinding{
|
||||||
|
{
|
||||||
|
HostIP: "127.0.0.1",
|
||||||
|
// Explicitly specify the mapped host port for the registry because if not specified,
|
||||||
|
// 'docker push' from Docker Desktop is unable to reach the randomly mapped one for some reason.
|
||||||
|
HostPort: "50000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c, _ := createTestCluster(t, clusterName, clusterOpts, true)
|
||||||
|
|
||||||
|
machine := c.Machines[0]
|
||||||
|
client, err := machine.Connect(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Get the (random) assigned host port for the future registry service
|
||||||
|
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||||
|
require.NoError(t, err)
|
||||||
|
containerInfo, err := dockerCli.ContainerInspect(ctx, machine.ContainerName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
portMap := containerInfo.NetworkSettings.Ports
|
||||||
|
registryHostPortStr := portMap[nat.Port(registryInternalPort)][0].HostPort
|
||||||
|
registryHostPort, err := strconv.Atoi(registryHostPortStr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, registryHostPort > 0, "Expected host port to be a valid number")
|
||||||
|
|
||||||
|
t.Run("build and push images for a basic project", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
registryServiceName := "registry"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
removeServices(t, client, registryServiceName)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start registry
|
||||||
|
resp, err := client.RunService(ctx, api.ServiceSpec{
|
||||||
|
Name: registryServiceName,
|
||||||
|
Mode: api.ServiceModeReplicated,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "registry:2",
|
||||||
|
},
|
||||||
|
Ports: []api.PortSpec{
|
||||||
|
{
|
||||||
|
ContainerPort: 5000,
|
||||||
|
PublishedPort: 5000,
|
||||||
|
Mode: api.PortModeHost,
|
||||||
|
Protocol: api.ProtocolTCP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, resp.ID)
|
||||||
|
assert.Equal(t, "registry", resp.Name)
|
||||||
|
|
||||||
|
project, err := compose.LoadProject(
|
||||||
|
ctx,
|
||||||
|
[]string{"fixtures/compose-build-basic/compose.yaml"},
|
||||||
|
composecli.WithEnv([]string{
|
||||||
|
fmt.Sprintf("TEST_REGISTRY_PORT=%s", registryHostPortStr),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
servicesToBuild := cliInternal.GetServicesThatNeedBuild(project)
|
||||||
|
serviceImage1 := fmt.Sprintf("127.0.0.1:%d/service-first", registryHostPort)
|
||||||
|
serviceImage2 := fmt.Sprintf("127.0.0.1:%d/service-second:version2", registryHostPort)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
// Remove the images after the test
|
||||||
|
removeOptions := image.RemoveOptions{Force: true, PruneChildren: true}
|
||||||
|
_, err := dockerCli.ImageRemove(ctx, serviceImage1, removeOptions)
|
||||||
|
assert.NoErrorf(t, err, "failed to remove image %s on test cleanup", serviceImage1)
|
||||||
|
_, err = dockerCli.ImageRemove(ctx, serviceImage2, removeOptions)
|
||||||
|
assert.NoErrorf(t, err, "failed to remove image %s on test cleanup", serviceImage2)
|
||||||
|
})
|
||||||
|
|
||||||
|
servicesToBuildExpected := map[string]types.ServiceConfig{
|
||||||
|
"service-first": {
|
||||||
|
Name: "service-first",
|
||||||
|
Build: &types.BuildConfig{
|
||||||
|
Context: path.Join(project.WorkingDir, "service-first-dir"),
|
||||||
|
Dockerfile: "Dockerfile",
|
||||||
|
},
|
||||||
|
Image: serviceImage1,
|
||||||
|
Environment: types.NewMappingWithEquals([]string{}),
|
||||||
|
Networks: map[string]*types.ServiceNetworkConfig{
|
||||||
|
"default": nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"service-second": {
|
||||||
|
Name: "service-second",
|
||||||
|
Build: &types.BuildConfig{
|
||||||
|
Context: path.Join(project.WorkingDir, "service-second-dir"),
|
||||||
|
Dockerfile: "Dockerfile.alt",
|
||||||
|
},
|
||||||
|
Image: serviceImage2,
|
||||||
|
Environment: types.NewMappingWithEquals([]string{}),
|
||||||
|
Networks: map[string]*types.ServiceNetworkConfig{
|
||||||
|
"default": nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Equal(t, servicesToBuildExpected, servicesToBuild)
|
||||||
|
|
||||||
|
// Build and push the images
|
||||||
|
buildOpts := cli.BuildOptions{
|
||||||
|
Push: true,
|
||||||
|
NoCache: false,
|
||||||
|
}
|
||||||
|
cli.BuildServices(context.Background(), servicesToBuild, buildOpts)
|
||||||
|
|
||||||
|
// Check the image of the first service
|
||||||
|
ref1, err := name.NewRepository(fmt.Sprintf("127.0.0.1:%d/service-first", registryHostPort))
|
||||||
|
require.NoError(t, err)
|
||||||
|
tags, err := remote.List(ref1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tags, []string{"latest"}, "Tags for service service-first do not match")
|
||||||
|
|
||||||
|
// Check the image of the second service
|
||||||
|
ref2, err := name.NewRepository(fmt.Sprintf("127.0.0.1:%d/service-second", registryHostPort))
|
||||||
|
require.NoError(t, err)
|
||||||
|
tags, err = remote.List(ref2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tags, []string{"version2"}, "Tags for service service-second do not match")
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
func TestComposeDeployment(t *testing.T) {
|
func TestComposeDeployment(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
clusterName := "ucind-test.compose"
|
clusterName := "ucind-test.compose-deploy"
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 3}, true)
|
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 3}, true)
|
||||||
|
|
||||||
@@ -121,6 +121,9 @@ func TestComposeDeployment(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, volumes, 2, "Expected 2 volumes to be created")
|
assert.Len(t, volumes, 2, "Expected 2 volumes to be created")
|
||||||
|
// Check that the volumes have the correct labels
|
||||||
|
assert.Equal(t, map[string]string{"uncloud.managed": ""}, volumes[0].Volume.Labels)
|
||||||
|
assert.Equal(t, map[string]string{"uncloud.managed": ""}, volumes[1].Volume.Labels)
|
||||||
|
|
||||||
data1Volume, data2Volume := volumes[0], volumes[1]
|
data1Volume, data2Volume := volumes[0], volumes[1]
|
||||||
if volumes[0].Volume.Name == "test-compose-volumes-data2" {
|
if volumes[0].Volume.Name == "test-compose-volumes-data2" {
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
service-no-build:
|
||||||
|
image: portainer/pause:3.9
|
||||||
|
|
||||||
|
service-first:
|
||||||
|
image: 127.0.0.1:${TEST_REGISTRY_PORT}/service-first
|
||||||
|
build:
|
||||||
|
context: service-first-dir/
|
||||||
|
|
||||||
|
service-second:
|
||||||
|
image: 127.0.0.1:${TEST_REGISTRY_PORT}/service-second:version2
|
||||||
|
build:
|
||||||
|
context: service-second-dir/
|
||||||
|
dockerfile: Dockerfile.alt
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
FROM portainer/pause:3.9
|
||||||
|
|
||||||
|
ENV SERVICE_NAME=service-first
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
FROM portainer/pause:3.9
|
||||||
|
|
||||||
|
ENV SERVICE_NAME=service-second
|
||||||
+209
-46
@@ -3,9 +3,9 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Deploy Docker Apps on Your Infrastructure | Uncloud</title>
|
<title>Self-host and scale web apps without Kubernetes complexity | Uncloud</title>
|
||||||
<meta name="description"
|
<meta name="description"
|
||||||
content="Uncloud helps you deploy container applications on your infrastructure — from cloud VMs to bare metal. Replace Heroku and Render, no Kubernetes required.">
|
content="Take your Docker Compose apps to production with zero-downtime deployments, automatic HTTPS, and cross-machine scaling. Self-hosting made reliable without the complexity.">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<link href="./style.css" rel="stylesheet">
|
<link href="./style.css" rel="stylesheet">
|
||||||
<link rel="apple-touch-icon" href="./images/apple-touch-icon.png" sizes="180x180"/>
|
<link rel="apple-touch-icon" href="./images/apple-touch-icon.png" sizes="180x180"/>
|
||||||
@@ -13,11 +13,12 @@
|
|||||||
<link rel="alternate icon" type="image/png" href="./images/favicon.png" sizes="96x96"/>
|
<link rel="alternate icon" type="image/png" href="./images/favicon.png" sizes="96x96"/>
|
||||||
|
|
||||||
<!-- OpenGraph tags for social media -->
|
<!-- OpenGraph tags for social media -->
|
||||||
<meta property="og:title" content="Deploy Docker Apps on Your Infrastructure | Uncloud"/>
|
<meta property="og:title"
|
||||||
|
content="Self-host and scale web apps without Kubernetes complexity | Uncloud"/>
|
||||||
<meta property="og:description"
|
<meta property="og:description"
|
||||||
content="Uncloud helps you deploy container applications on your infrastructure — from cloud VMs to bare metal. Replace Heroku and Render, no Kubernetes required."/>
|
content="Take your Docker Compose apps to production with zero-downtime deployments, automatic HTTPS, and cross-machine scaling. Self-hosting made reliable without the complexity."/>
|
||||||
<meta property="og:image" content="https://uncloud.run/images/logo-wide.png"/>
|
<meta property="og:image" content="https://uncloud.run/images/logo-wide.png"/>
|
||||||
<meta property="og:image:alt" content="Uncloud logo - Deploy container apps with ease"/>
|
<meta property="og:image:alt" content="Uncloud logo - Self-host web apps with ease"/>
|
||||||
<meta property="og:url" content="https://uncloud.run"/>
|
<meta property="og:url" content="https://uncloud.run"/>
|
||||||
<meta property="og:type" content="website"/>
|
<meta property="og:type" content="website"/>
|
||||||
<meta property="og:site_name" content="Uncloud"/>
|
<meta property="og:site_name" content="Uncloud"/>
|
||||||
@@ -25,11 +26,12 @@
|
|||||||
<meta property="twitter:domain" content="uncloud.run">
|
<meta property="twitter:domain" content="uncloud.run">
|
||||||
<meta property="twitter:url" content="https://uncloud.run">
|
<meta property="twitter:url" content="https://uncloud.run">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Deploy Docker Apps on Your Infrastructure | Uncloud">
|
<meta name="twitter:title"
|
||||||
|
content="Self-host and scale web apps without Kubernetes complexity | Uncloud">
|
||||||
<meta name="twitter:description"
|
<meta name="twitter:description"
|
||||||
content="Uncloud helps you deploy container applications on your infrastructure — from cloud VMs to bare metal. Replace Heroku and Render, no Kubernetes required.">
|
content="Take your Docker Compose apps to production with zero-downtime deployments, automatic HTTPS, and cross-machine scaling. Self-hosting made reliable without the complexity.">
|
||||||
<meta name="twitter:image" content="https://uncloud.run/images/logo-wide.png">
|
<meta name="twitter:image" content="https://uncloud.run/images/logo-wide.png">
|
||||||
<meta name="twitter:image:alt" content="Uncloud logo - Deploy container apps with ease">
|
<meta name="twitter:image:alt" content="Uncloud logo - Self-host web apps with ease">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="font-inter antialiased bg-white text-zinc-900 tracking-tight">
|
<body class="font-inter antialiased bg-white text-zinc-900 tracking-tight">
|
||||||
@@ -54,19 +56,40 @@
|
|||||||
<ul class="flex grow justify-end flex-wrap items-center">
|
<ul class="flex grow justify-end flex-wrap items-center">
|
||||||
<li>
|
<li>
|
||||||
<a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-3 lg:px-5 py-2 flex items-center transition"
|
<a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-3 lg:px-5 py-2 flex items-center transition"
|
||||||
href="https://github.com/psviderski/uncloud">
|
href="https://docs.uncloud.run">
|
||||||
<svg class="w-4 h-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
<svg class="w-4 h-4 mr-1 fill-current hidden sm:inline"
|
||||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-linejoin="round">
|
viewBox="0 0 24 24">
|
||||||
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
|
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
|
||||||
<path d="M9 18c-4.51 2-5-2-7-2"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span class="ml-1 hidden sm:inline">View Source</span>
|
<span class="ml-1">Docs</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-3 lg:px-5 py-2 flex items-center transition"
|
||||||
|
href="https://discord.gg/eR35KQJhPu">
|
||||||
|
<svg class="w-4 h-4 mr-1 fill-current" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-1 hidden sm:inline">Discord</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-3 lg:px-5 py-2 flex items-center transition"
|
||||||
|
href="https://github.com/psviderski/uncloud">
|
||||||
|
<svg class="w-4 h-4 mr-1 fill-current" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-1 hidden sm:inline">GitHub</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="ml-1">
|
<li class="ml-1">
|
||||||
<a class="btn-sm text-zinc-100 bg-zinc-900 hover:bg-zinc-800 w-full shadow"
|
<a class="btn-sm text-zinc-100 bg-zinc-900 hover:bg-zinc-800 w-full shadow"
|
||||||
href="https://github.com/psviderski/uncloud/?tab=readme-ov-file#-quick-start">Get Started</a>
|
href="https://github.com/psviderski/uncloud/?tab=readme-ov-file#-quick-start">
|
||||||
|
Get Started
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -86,18 +109,19 @@
|
|||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<div class="text-center pb-12 md:pb-16">
|
<div class="text-center pb-12 md:pb-16">
|
||||||
<h1 class="font-inter-tight text-4xl md:text-5xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-zinc-500 via-zinc-900 to-zinc-900 pb-4">
|
<h1 class="font-inter-tight text-4xl md:text-5xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-zinc-500 via-zinc-900 to-zinc-900 pb-4">
|
||||||
Run on your servers<br>
|
Self-host and scale web apps<br>
|
||||||
Cut cloud costs
|
without the complexity
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg text-zinc-500 mb-8">
|
<p class="text-lg text-zinc-500 mb-8">
|
||||||
Uncloud is a lightweight Docker clustering tool for running web apps on your own servers
|
Take your Docker Compose apps to production with zero-downtime deployments, automatic
|
||||||
— from cloud VMs to bare metal.
|
HTTPS, and cross-machine scaling. No Kubernetes required.
|
||||||
Replace Heroku and Render, no Kubernetes required.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="max-w-xs mx-auto sm:max-w-none sm:inline-flex sm:justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
<div class="max-w-xs mx-auto sm:max-w-none sm:inline-flex sm:justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||||
<div>
|
<div>
|
||||||
<a class="btn text-zinc-100 bg-zinc-900 hover:bg-zinc-800 w-full shadow"
|
<a class="btn text-zinc-100 bg-zinc-900 hover:bg-zinc-800 w-full shadow"
|
||||||
href="https://github.com/psviderski/uncloud/?tab=readme-ov-file#-quick-start">Quick Start</a>
|
href="https://github.com/psviderski/uncloud/?tab=readme-ov-file#-quick-start">
|
||||||
|
Deploy in 2 minutes
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a class="btn text-zinc-600 bg-white hover:text-zinc-900 w-full shadow flex items-center justify-center"
|
<a class="btn text-zinc-600 bg-white hover:text-zinc-900 w-full shadow flex items-center justify-center"
|
||||||
@@ -117,16 +141,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 flex justify-center pb-12 relative before:absolute before:-top-12 before:w-96 before:h-96 before:bg-zinc-900 before:opacity-[.15] before:rounded-full before:blur-3xl before:-z-10">
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 flex justify-center pb-12 relative before:absolute before:-top-12 before:w-96 before:h-96 before:bg-zinc-900 before:opacity-[.15] before:rounded-full before:blur-3xl before:-z-10">
|
||||||
<div class="w-full md:min-h-[420px]">
|
<div class="coding w-full bg-zinc-800 rounded-lg border border-zinc-700/50 shadow-2xl">
|
||||||
<script src="https://fast.wistia.com/player.js" async></script>
|
<!-- Title bar -->
|
||||||
<script src="https://fast.wistia.com/embed/k47uwt9uau.js" async type="module"></script>
|
<div class="pl-4 pt-4 flex items-center">
|
||||||
<style>wistia-player[media-id='k47uwt9uau']:not(:defined) {
|
<div class="flex gap-2">
|
||||||
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/k47uwt9uau/swatch');
|
<div class="w-2.5 h-2.5 rounded-full bg-red-500"></div>
|
||||||
display: block;
|
<div class="w-2.5 h-2.5 rounded-full bg-yellow-500"></div>
|
||||||
filter: blur(5px);
|
<div class="w-2.5 h-2.5 rounded-full bg-green-500"></div>
|
||||||
padding-top: 58.33%;
|
</div>
|
||||||
}</style>
|
</div>
|
||||||
<wistia-player media-id="k47uwt9uau" aspect="1.7142857142857142"></wistia-player>
|
<!-- Terminal content -->
|
||||||
|
<div class="p-4 text-sm sm:p-6 sm:text-[0.9375rem] text-white overflow-x-auto">
|
||||||
|
<pre class="text-zinc-500 select-none"># Start with any cloud VM or your own server</pre>
|
||||||
|
<pre><span class="text-emerald-500 select-none">$ </span>uc machine init root@137.123.45.67</pre>
|
||||||
|
|
||||||
|
<pre> </pre>
|
||||||
|
<pre class="text-zinc-500 select-none"># Deploy your app with automatic HTTPS</pre>
|
||||||
|
<pre><span class="text-emerald-500 select-none">$ </span>uc run --name my-app -p app.example.com:8000/https app-image:latest</pre>
|
||||||
|
<pre class="text-zinc-200">✨ Your app is available at <span class="underline">https://app.example.com</span></pre>
|
||||||
|
|
||||||
|
<pre> </pre>
|
||||||
|
<pre class="text-zinc-500 select-none"># Achieve high availability by adding more machines and scaling the app</pre>
|
||||||
|
<pre><span
|
||||||
|
class="text-emerald-500 select-none">$ </span>uc machine add ubuntu@65.21.45.67</pre>
|
||||||
|
<pre><span class="text-emerald-500 select-none">$ </span>uc scale my-app 2</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +180,7 @@
|
|||||||
Cloud-like experience you control
|
Cloud-like experience you control
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-lg text-zinc-500">
|
<p class="text-lg text-zinc-500">
|
||||||
Keep the Docker workflows you love while optimizing infrastructure costs
|
Keep the Docker Compose workflows you love while deploying anywhere with ease.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -362,7 +401,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fully decentralized -->
|
<!-- Fully decentralised -->
|
||||||
<div class="text-left flex items-center px-6 py-4 rounded border border-transparent">
|
<div class="text-left flex items-center px-6 py-4 rounded border border-transparent">
|
||||||
<svg class="shrink-0 fill-zinc-400 stroke-zinc-400 mr-3"
|
<svg class="shrink-0 fill-zinc-400 stroke-zinc-400 mr-3"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -371,11 +410,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-inter-tight text-lg font-semibold text-zinc-200 mb-1">
|
<div class="font-inter-tight text-lg font-semibold text-zinc-200 mb-1">
|
||||||
Fully decentralized
|
Fully decentralised
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Unlike traditional orchestrators, there's no central control plane to
|
Unlike traditional orchestrators, there's no central control plane to
|
||||||
maintain. Each machine maintains a synchronized copy of the cluster
|
maintain. Each machine maintains a synchronised copy of the cluster
|
||||||
state
|
state
|
||||||
through peer-to-peer communication, keeping cluster operations
|
through peer-to-peer communication, keeping cluster operations
|
||||||
functional
|
functional
|
||||||
@@ -502,7 +541,7 @@
|
|||||||
<h3 class="font-inter-tight font-semibold text-zinc-200">No vendor lock-in</h3>
|
<h3 class="font-inter-tight font-semibold text-zinc-200">No vendor lock-in</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm">Mix cloud providers and your own hardware freely to
|
<p class="text-sm">Mix cloud providers and your own hardware freely to
|
||||||
optimize costs and performance, without changing how you deploy or manage apps.</p>
|
optimise costs and performance, without changing how you deploy or manage apps.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -510,9 +549,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Demo Section -->
|
||||||
|
<section class="relative bg-white text-zinc-800">
|
||||||
|
<div class="py-12 md:py-20">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||||
|
<div class="max-w-3xl mx-auto text-center pb-12 md:pb-16">
|
||||||
|
<h2 class="font-inter-tight text-3xl md:text-4xl font-bold text-zinc-900 mb-4">
|
||||||
|
See in action
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-zinc-500">
|
||||||
|
Deploy a highly available web app with automatic HTTPS across multiple regions and
|
||||||
|
on-premises in just a couple minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 flex justify-center pb-12 relative before:absolute before:-top-12 before:w-96 before:h-96 before:bg-zinc-900 before:opacity-[.15] before:rounded-full before:blur-3xl before:-z-10">
|
||||||
|
<script src="https://fast.wistia.com/player.js" async></script>
|
||||||
|
<script src="https://fast.wistia.com/embed/k47uwt9uau.js" async type="module"></script>
|
||||||
|
<style>wistia-player[media-id='k47uwt9uau']:not(:defined) {
|
||||||
|
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/k47uwt9uau/swatch');
|
||||||
|
display: block;
|
||||||
|
filter: blur(5px);
|
||||||
|
padding-top: 58.33%;
|
||||||
|
}</style>
|
||||||
|
<wistia-player media-id="k47uwt9uau" aspect="1.7142857142857142"
|
||||||
|
class="w-full rounded-xl shadow-lg"></wistia-player>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- CTA -->
|
<!-- CTA -->
|
||||||
<section id="subscribe">
|
<section id="subscribe">
|
||||||
<div class="py-12 md:py-20">
|
<div class="py-12 md:py-20 border-t border-zinc-200">
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||||
<div class="relative max-w-3xl mx-auto text-center">
|
<div class="relative max-w-3xl mx-auto text-center">
|
||||||
<h2 class="font-inter-tight text-3xl md:text-4xl font-bold text-zinc-900 mb-4">
|
<h2 class="font-inter-tight text-3xl md:text-4xl font-bold text-zinc-900 mb-4">
|
||||||
@@ -529,7 +598,7 @@
|
|||||||
<p class="text-lg text-zinc-500 mb-8">
|
<p class="text-lg text-zinc-500 mb-8">
|
||||||
Uncloud is an open source project I'm actively developing. I'd love to share this journey
|
Uncloud is an open source project I'm actively developing. I'd love to share this journey
|
||||||
with you. Subscribe to follow the progress, get early insights into new features, and be the
|
with you. Subscribe to follow the progress, get early insights into new features, and be the
|
||||||
first to know when it's ready for production use.
|
first to know when it's ready for production use. No marketing, ads or spam.
|
||||||
<p>
|
<p>
|
||||||
|
|
||||||
<!-- Subscribe form -->
|
<!-- Subscribe form -->
|
||||||
@@ -561,9 +630,9 @@
|
|||||||
Want to contribute? Check out the
|
Want to contribute? Check out the
|
||||||
<a href="https://github.com/psviderski/uncloud/issues"
|
<a href="https://github.com/psviderski/uncloud/issues"
|
||||||
class="text-zinc-900 hover:underline">GitHub issues</a>
|
class="text-zinc-900 hover:underline">GitHub issues</a>
|
||||||
or join the
|
and join the
|
||||||
<a href="https://github.com/psviderski/uncloud/discussions"
|
<a href="https://discord.gg/eR35KQJhPu"
|
||||||
class="text-zinc-900 hover:underline">GitHub Discussions</a>.
|
class="text-zinc-900 hover:underline">Discord server</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,10 +645,104 @@
|
|||||||
<!-- Site footer -->
|
<!-- Site footer -->
|
||||||
<footer>
|
<footer>
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||||
<div class="flex justify-center py-8 md:py-12 border-t border-zinc-200">
|
<!-- Top area: Blocks -->
|
||||||
<div class="text-sm text-zinc-500">
|
<div class="grid sm:grid-cols-12 gap-8 py-8 md:py-12 border-t border-zinc-200">
|
||||||
Made with ❤️ by
|
<!-- 1st block - Brand -->
|
||||||
<a class="underline hover:text-zinc-900" href="https://github.com/psviderski">Pasha Sviderski</a>
|
<div class="sm:col-span-6 md:col-span-6 lg:col-span-8 max-md:order-1 flex flex-col">
|
||||||
|
<div class="mb-4">
|
||||||
|
<!-- Logo -->
|
||||||
|
<a class="flex items-center" href="index.html">
|
||||||
|
<img class="w-8 h-8 rounded shadow-sm shadow-zinc-950/20" src="./images/logo.svg"
|
||||||
|
alt="Logo">
|
||||||
|
<span class="ml-2 font-semibold text-lg text-zinc-900">uncloud</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="grow text-sm text-zinc-500">
|
||||||
|
Made with ❤️ by
|
||||||
|
<a class="underline hover:text-zinc-900" href="https://github.com/psviderski">
|
||||||
|
Pasha Sviderski
|
||||||
|
</a>
|
||||||
|
in Australia
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Social links -->
|
||||||
|
<ul class="flex space-x-4 mt-4 mb-1">
|
||||||
|
<li>
|
||||||
|
<a class="flex justify-center items-center text-zinc-700 hover:text-zinc-900 transition"
|
||||||
|
href="https://discord.gg/eR35KQJhPu" aria-label="Discord">
|
||||||
|
<svg class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||||
|
width="20"
|
||||||
|
height="20">
|
||||||
|
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="flex justify-center items-center text-zinc-700 hover:text-zinc-900 transition"
|
||||||
|
href="https://x.com/psviderski" aria-label="X/Twitter">
|
||||||
|
<svg class="fill-current" xmlns="http://www.w3.org/2000/svg" width="20" height="20">
|
||||||
|
<path d="m7.063 3 3.495 4.475L14.601 3h2.454l-5.359 5.931L18 17h-4.938l-3.866-4.893L4.771 17H2.316l5.735-6.342L2 3h5.063Zm-.74 1.347H4.866l8.875 11.232h1.36L6.323 4.347Z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="flex justify-center items-center text-zinc-700 hover:text-zinc-900 transition"
|
||||||
|
href="https://t.me/spyyy" aria-label="Telegram">
|
||||||
|
<svg class="fill-current" xmlns="http://www.w3.org/2000/svg" width="20" height="20">
|
||||||
|
<path d="M17.968 3.276a.338.338 0 0 0-.232-.253 1.192 1.192 0 0 0-.63.045S3.087 8.106 2.286 8.664c-.172.121-.23.19-.259.272-.138.4.293.573.293.573l3.613 1.177a.388.388 0 0 0 .183-.011c.822-.519 8.27-5.222 8.7-5.38.068-.02.118 0 .1.049-.172.6-6.606 6.319-6.64 6.354a.138.138 0 0 0-.05.118l-.337 3.528s-.142 1.1.956 0a30.66 30.66 0 0 1 1.9-1.738c1.242.858 2.58 1.806 3.156 2.3a1 1 0 0 0 .732.283.825.825 0 0 0 .7-.622S17.894 5.292 17.98 3.909c.008-.135.021-.217.021-.317a1.177 1.177 0 0 0-.032-.316Z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="flex justify-center items-center text-zinc-700 hover:text-zinc-900 transition"
|
||||||
|
href="https://github.com/psviderski/uncloud" aria-label="GitHub">
|
||||||
|
<svg class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||||
|
width="20"
|
||||||
|
height="20">
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2nd block - Documentation -->
|
||||||
|
<div class="sm:col-span-6 md:col-span-3 lg:col-span-2">
|
||||||
|
|
||||||
|
<h6 class="text-sm text-zinc-800 font-medium mb-2">Documentation</h6>
|
||||||
|
<ul class="text-sm space-y-2">
|
||||||
|
<li>
|
||||||
|
<a class="text-zinc-500 hover:text-zinc-900 transition"
|
||||||
|
href="https://docs.uncloud.run/#use-cases">Use cases</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="text-zinc-500 hover:text-zinc-900 transition"
|
||||||
|
href="https://docs.uncloud.run/getting-started/quick-start">Quick start</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3rd block - Resources -->
|
||||||
|
<div class="sm:col-span-6 md:col-span-3 lg:col-span-2">
|
||||||
|
<h6 class="text-sm text-zinc-800 font-medium mb-2">Resources</h6>
|
||||||
|
<ul class="text-sm space-y-2">
|
||||||
|
<li>
|
||||||
|
<a class="text-zinc-500 hover:text-zinc-900 transition"
|
||||||
|
href="https://github.com/psviderski/uncloud">GitHub project</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="text-zinc-500 hover:text-zinc-900 transition"
|
||||||
|
href="https://github.com/psviderski/uncloud/releases">Releases</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="text-zinc-500 hover:text-zinc-900 transition"
|
||||||
|
href="https://github.com/psviderski/uncloud/blob/main/LICENSE">License</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="text-zinc-500 hover:text-zinc-900 transition"
|
||||||
|
href="https://discord.gg/eR35KQJhPu">Community</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+128
-4
@@ -1222,6 +1222,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mb-2{
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mb-4{
|
.mb-4{
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -1314,6 +1318,14 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
height: 3.5rem;
|
height: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-2{
|
||||||
|
height: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-2\.5{
|
||||||
|
height: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
.h-24{
|
.h-24{
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
}
|
}
|
||||||
@@ -1334,6 +1346,14 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-2{
|
||||||
|
width: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-2\.5{
|
||||||
|
width: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
.w-4{
|
.w-4{
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
}
|
}
|
||||||
@@ -1402,6 +1422,12 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.select-none{
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-cols-1{
|
.grid-cols-1{
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -1446,6 +1472,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gap-2{
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.gap-3{
|
.gap-3{
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -1508,6 +1538,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto{
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.rounded{
|
.rounded{
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
@@ -1586,6 +1620,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
border-color: rgb(228 228 231 / var(--tw-border-opacity));
|
border-color: rgb(228 228 231 / var(--tw-border-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-zinc-700\/50{
|
||||||
|
border-color: rgb(63 63 70 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.bg-gray-100{
|
.bg-gray-100{
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
||||||
@@ -1596,11 +1634,26 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
|
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-green-500{
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-red-500{
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.bg-white{
|
.bg-white{
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-yellow-500{
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.bg-zinc-50{
|
.bg-zinc-50{
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(250 250 250 / var(--tw-bg-opacity));
|
background-color: rgb(250 250 250 / var(--tw-bg-opacity));
|
||||||
@@ -1640,6 +1693,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fill-current{
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.fill-zinc-300{
|
.fill-zinc-300{
|
||||||
fill: #d4d4d8;
|
fill: #d4d4d8;
|
||||||
}
|
}
|
||||||
@@ -1652,6 +1709,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
stroke: #a1a1aa;
|
stroke: #a1a1aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.p-4{
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.p-5{
|
.p-5{
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
@@ -1719,10 +1780,18 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pl-4{
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pt-32{
|
.pt-32{
|
||||||
padding-top: 8rem;
|
padding-top: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pt-4{
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pt-6{
|
.pt-6{
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -1836,6 +1905,11 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-white{
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.text-zinc-100{
|
.text-zinc-100{
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(244 244 245 / var(--tw-text-opacity));
|
color: rgb(244 244 245 / var(--tw-text-opacity));
|
||||||
@@ -1861,6 +1935,16 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
color: rgb(82 82 91 / var(--tw-text-opacity));
|
color: rgb(82 82 91 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-zinc-700{
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(63 63 70 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-zinc-800{
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(39 39 42 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.text-zinc-900{
|
.text-zinc-900{
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(24 24 27 / var(--tw-text-opacity));
|
color: rgb(24 24 27 / var(--tw-text-opacity));
|
||||||
@@ -1887,6 +1971,12 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shadow-lg{
|
||||||
|
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
.shadow-sm{
|
.shadow-sm{
|
||||||
--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
|
--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
|
||||||
@@ -2156,7 +2246,17 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media not all and (min-width: 768px){
|
||||||
|
.max-md\:order-1{
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 640px){
|
@media (min-width: 640px){
|
||||||
|
.sm\:col-span-6{
|
||||||
|
grid-column: span 6 / span 6;
|
||||||
|
}
|
||||||
|
|
||||||
.sm\:inline{
|
.sm\:inline{
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
@@ -2169,6 +2269,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sm\:grid-cols-12{
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.sm\:grid-cols-2{
|
.sm\:grid-cols-2{
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -2197,10 +2301,18 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
margin-bottom: calc(0px * var(--tw-space-y-reverse));
|
margin-bottom: calc(0px * var(--tw-space-y-reverse));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sm\:p-6{
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sm\:px-6{
|
.sm\:px-6{
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
padding-right: 1.5rem;
|
padding-right: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sm\:text-\[0\.9375rem\]{
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px){
|
@media (min-width: 768px){
|
||||||
@@ -2208,12 +2320,16 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
top: 1.5rem;
|
top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md\:mb-0{
|
.md\:col-span-3{
|
||||||
margin-bottom: 0px;
|
grid-column: span 3 / span 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md\:min-h-\[420px\]{
|
.md\:col-span-6{
|
||||||
min-height: 420px;
|
grid-column: span 6 / span 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:mb-0{
|
||||||
|
margin-bottom: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md\:max-w-4xl{
|
.md\:max-w-4xl{
|
||||||
@@ -2264,6 +2380,14 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px){
|
@media (min-width: 1024px){
|
||||||
|
.lg\:col-span-2{
|
||||||
|
grid-column: span 2 / span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg\:col-span-8{
|
||||||
|
grid-column: span 8 / span 8;
|
||||||
|
}
|
||||||
|
|
||||||
.lg\:mt-24{
|
.lg\:mt-24{
|
||||||
margin-top: 6rem;
|
margin-top: 6rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user