Compare commits

..
Author SHA1 Message Date
Pasha Sviderski 59b96cc01f feat: init 'machine rm' command that outputs service containers that will be removed 2025-07-01 19:51:57 +10:00
Pasha Sviderski 4c77fe2cfd chore: tidy up 2025-06-30 17:53:34 +10:00
Pavel Sviderski 3b89af4a11 fix: e2e compose build test on macOS with Docker Desktop 2025-06-27 17:54:02 +10:00
Zasda Yusuf MikailandGitHub 1abadaef50 feat: set default log driver for service containers to 'local' (#83) 2025-06-27 16:42:26 +10:00
Krishna SantoshandGitHub 3b1ce42dd7 fix(docs): add missing word to complete the sentence (#82) 2025-06-24 16:49:07 +10:00
Pavel Sviderski 1640b4c433 docs: mention github discussions for help in README 2025-06-24 11:40:06 +10:00
Anton OvchinnikovandGitHub 44549c00fd feat: Label new volumes as managed by uncloud (#79) 2025-06-23 22:05:05 +02:00
Anton Ovchinnikov 26f34df3e6 fix: Improve output for build operations 2025-06-22 19:26:25 +02:00
Anton Ovchinnikov 89fb9efcfa fix: Use GITHUB_TOKEN to increase the rate limit for mise action 2025-06-22 11:50:17 +02:00
Anton OvchinnikovandGitHub 638f320959 build: Install protobuf tools via mise (#77) 2025-06-22 11:23:56 +02:00
Pavel Sviderski 3f2e60ea74 docs: mention unregistry as comming soon feature 2025-06-22 09:38:15 +10:00
Pavel Sviderski 8e0a24e781 chore: add donate button in README 2025-06-20 13:37:50 +10:00
Pasha SviderskiandGitHub e3e39dde32 chore: create FUNDING.yml 2025-06-20 12:36:58 +10:00
Pavel Sviderski fc61e2fc07 feat: enable passive health checks and upstream retries in Caddy 2025-06-12 18:38:28 +10:00
Anton Ovchinnikov 2a0d31cec0 chore: Helper make target to install protobuf/grpc plugins 2025-06-11 23:57:50 +02:00
Anton OvchinnikovandGitHub 2f55af0cab test: Add an e2e test for build&push (#69)
* test: Add an e2e test for build&push

* test: Add image cleanup

* test: Switch to assert.NoError
2025-06-09 22:10:53 +02:00
Anton OvchinnikovandGitHub 971b21d6c1 feat: Implement basic build & push capabilities for services (#68)
* wip: Build experiments

* wip: overall structure for build/push

* ref: Split method, add test fixture

* feat: Basic push functionality

* ref: Better structure and output

* feat: Handle registry auth

* feat: Integrate with deploy

* ref: Split into files

* fix: Modernize push options
2025-06-03 21:35:54 +02:00
Pavel Sviderski b651d95183 docs: demo app - Compose and custom domain 2025-06-02 20:03:19 +10:00
Pavel Sviderski d45c1b6869 docs: add Deploy demo app page (partial), change prism theme to palenight 2025-05-30 20:00:04 +10:00
Pavel Sviderski a5fe9fbfb2 chore: update -p flag help for run command 2025-05-29 21:47:50 +10:00
Pavel Sviderski 4590a516ad chore: set default ssh key path to ~/.ssh/id_ed25519 and handle home expansion 2025-05-29 15:00:33 +10:00
Pavel Sviderski 5f9ed6dacf docs: fix links 2025-05-28 18:39:08 +10:00
Pavel Sviderski e97497d737 docs: refine overview 2025-05-28 18:35:23 +10:00
Pavel Sviderski b4bf1d17d0 docs: add note for Windows installation, enable tabs support 2025-05-28 11:14:27 +10:00
Pavel Sviderski cfb4693286 docs: installation page 2025-05-28 10:28:33 +10:00
Pavel Sviderski fd3facb6b0 chore: minor README change 2025-05-27 16:16:26 +10:00
Pavel Sviderski bd03bb9bfa chore: minor README change 2025-05-27 16:12:32 +10:00
Pavel Sviderski eec728620b chore: minor README change 2025-05-27 16:09:20 +10:00
Pavel Sviderski d2dd5e3c89 chore: add inspiration & acknowledgements section to README 2025-05-27 16:07:30 +10:00
Pavel Sviderski 2bfcce7803 chore: add uncloud-docs deploy from compose to demo section in README 2025-05-27 13:41:30 +10:00
Pavel Sviderski 1ec5184f21 chore: self-hosting focused front page (first screen) 2025-05-26 18:50:24 +10:00
Pavel Sviderski 23dfe45b2f chore: add docs badge to github README 2025-05-26 12:29:51 +10:00
Anton Ovchinnikov d9e906a77e test: Disable test caching 2025-05-25 19:53:43 +02:00
Anton Ovchinnikov 51f7ebf3bf test: Add cleanup target that removes dangling test containers 2025-05-25 19:46:13 +02:00
Pavel Sviderski ae04dea808 chore: add Docs to website navbar and footer, add other resources to footer 2025-05-23 20:11:11 +10:00
Pavel Sviderski 009c0e1b4c chore: scale docs service to 2 replicas in different regions 2025-05-23 18:09:54 +10:00
Pavel Sviderski 18d38e96b4 docs: minor overview polish 2025-05-23 16:31:29 +10:00
Pavel Sviderski 12c86f1dd4 fix: use existing cluster client when deploying caddy after adding new machine #65 2025-05-23 16:31:07 +10:00
Anton Ovchinnikov 4df1820f96 fix(doc): Tiny fixes 2025-05-22 22:19:02 +02:00
Pavel Sviderski 6f9ac1fcc7 chore: add Docker image for docs and Uncloud Compose deployment (dogfooding!) 2025-05-22 21:01:52 +10:00
Pavel Sviderski 6a98acbe79 docs: add Overview page and Getting started stubs 2025-05-22 18:37:10 +10:00
Pavel Sviderski b0059af9b1 docs: change color palette for links to blue 2025-05-22 18:36:35 +10:00
Pavel Sviderski 7edd8f2976 chore: add m alias for machine command 2025-05-21 15:46:31 +10:00
Pavel Sviderski 735e36012a chore: delete unused ucind-image-push target in Makefile 2025-05-21 15:46:09 +10:00
69ebce57e9 test: Add basic Github Actions config (#63)
* test: Add basic Github Actions config

* fix(e2e-tests): handle asynchronous port publishing for ucind container

* fix(e2e-tests): increase machine ready wait timeout 1->10s

* fix: Pin GHA versions

---------

Co-authored-by: Pavel Sviderski <me@psviderski.name>
2025-05-20 23:59:02 +02:00
Pavel Sviderski 365a5fc0b5 chore: build multiarch images for corrosion and ucind to support e2e tests on amd64 2025-05-20 12:47:27 +10:00
58 changed files with 1895 additions and 275 deletions
+1
View File
@@ -0,0 +1 @@
github: [psviderski]
Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

+67
View File
@@ -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)
+1
View File
@@ -30,6 +30,7 @@ go.work.sum
/data/ /data/
/dist/ /dist/
/uc /uc
/tmp
# Web # Web
node_modules/ node_modules/
+31
View File
@@ -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"
+6
View File
@@ -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
View File
@@ -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"]
+38 -6
View File
@@ -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 && \
@@ -49,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
+46 -4
View File
@@ -4,8 +4,10 @@
<p><strong>Docker simplicity. Multi-machine power.</strong></p> <p><strong>Docker simplicity. Multi-machine power.</strong></p>
<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://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://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> </p>
</div> </div>
@@ -43,9 +45,23 @@ complexity of Kubernetes.
* **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
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Push your Docker images directly to your
machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
## 🎬 Quick demo ## 🎬 Quick demo
Deploy a highly available web app with automatic HTTPS across multiple regions and on-premises in just a couple minutes. 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.
![Uncloud compose deployment demo](.github/images/compose-deploy.jpg)
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"> <a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" /> <img src="https://embed-ssl.wistia.com/deliveries/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" />
@@ -112,7 +128,7 @@ 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
@@ -268,8 +284,34 @@ and there may be breaking changes between releases.
We'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 or need help? [Join our Discord community](https://discord.gg/eR35KQJhPu) where we discuss features, * 💡 Have questions, ideas, or need help?
roadmap, implementation details, and help each other out. * 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
+77
View File
@@ -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)
}
+21
View File
@@ -20,6 +20,7 @@ type deployOptions struct {
files []string files []string
profiles []string profiles []string
services []string services []string
noBuild bool
context string context string
} }
@@ -47,6 +48,9 @@ func NewDeployCommand() *cobra.Command {
"One or more Compose profiles to enable.") "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.
@@ -81,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)
+9 -5
View File
@@ -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)
} }
+2 -3
View File
@@ -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,
+5 -4
View File
@@ -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)
} }
+139
View File
@@ -0,0 +1,139 @@
package machine
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/tree"
"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 {
fmt.Printf("Found %d service containers on machine '%s':\n\n", len(containers), 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
}
}
// TODO: 3. Remove all service containers on the machine.
// TODO: 4. Implement and call ResetMachine via Machine API to reset the machine state to uninitialised.
// TODO: 5. Remove the machine from the cluster store.
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")
}
+4 -2
View File
@@ -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
+1
View File
@@ -75,6 +75,7 @@ func main() {
cmd.AddCommand( cmd.AddCommand(
NewDeployCommand(), NewDeployCommand(),
NewBuildCommand(),
caddy.NewRootCommand(), caddy.NewRootCommand(),
cmdcontext.NewRootCommand(), cmdcontext.NewRootCommand(),
dns.NewRootCommand(), dns.NewRootCommand(),
+4 -3
View File
@@ -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').",
-1
View File
@@ -55,5 +55,4 @@ func main() {
}() }()
cobra.CheckErr(cmd.ExecuteContext(ctx)) cobra.CheckErr(cmd.ExecuteContext(ctx))
} }
+14
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
{
admin off
}
:8000 {
root * /usr/share/caddy
file_server
try_files {path} /index.html
}
+27
View File
@@ -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
+8
View File
@@ -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
+115
View File
@@ -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,104 @@
# 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
```
## 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.
![Excalidraw running on Uncloud](./img/excalidraw-browser.png)
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

-9
View File
@@ -1,9 +0,0 @@
---
slug: /
---
# Overview
Uncloud is lightweight tool for creating a cluster of machines running Docker and deploying web applications across
them. It is designed to be simple and easy to use, while still providing the essential features to manage and scale
your apps in production.
+2 -2
View File
@@ -144,8 +144,8 @@ const config = {
] ]
}, },
prism: { prism: {
theme: prismThemes.github, theme: prismThemes.palenight,
darkTheme: prismThemes.dracula, //darkTheme: prismThemes.dracula,
}, },
}), }),
}; };
+1 -1
View File
@@ -16,7 +16,7 @@
*/ */
const sidebars = { const sidebars = {
// By default, Docusaurus generates a sidebar from the docs folder structure // By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], sidebar: [{type: 'autogenerated', dirName: '.'}],
// But you can create a sidebar manually // But you can create a sidebar manually
/* /*
+14 -14
View File
@@ -11,26 +11,26 @@
--ifm-font-family-base: 'Inter', sans-serif; --ifm-font-family-base: 'Inter', sans-serif;
--ifm-heading-font-family: 'Inter', sans-serif; --ifm-heading-font-family: 'Inter', sans-serif;
--ifm-color-primary: #2e8555; --ifm-color-primary: #006be6;
--ifm-color-primary-dark: #29784c; --ifm-color-primary-dark: #0060cf;
--ifm-color-primary-darker: #277148; --ifm-color-primary-darker: #005bc4;
--ifm-color-primary-darkest: #205d3b; --ifm-color-primary-darkest: #004ba1;
--ifm-color-primary-light: #33925d; --ifm-color-primary-light: #0076fd;
--ifm-color-primary-lighter: #359962; --ifm-color-primary-lighter: #0a7cff;
--ifm-color-primary-lightest: #3cad6e; --ifm-color-primary-lightest: #2c8eff;
--ifm-code-font-size: 95%; --ifm-code-font-size: 95%;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
} }
/* For readability concerns, you should choose a lighter palette in dark mode. */ /* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme='dark'] { [data-theme='dark'] {
--ifm-color-primary: #25c2a0; --ifm-color-primary: #3391ee;
--ifm-color-primary-dark: #21af90; --ifm-color-primary-dark: #1883ec;
--ifm-color-primary-darker: #1fa588; --ifm-color-primary-darker: #137be3;
--ifm-color-primary-darkest: #1a8870; --ifm-color-primary-darkest: #1066bb;
--ifm-color-primary-light: #29d5b0; --ifm-color-primary-light: #4e9ff0;
--ifm-color-primary-lighter: #32d8b4; --ifm-color-primary-lighter: #5ba7f1;
--ifm-color-primary-lightest: #4fddbf; --ifm-color-primary-lightest: #83bcf5;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
} }
+13
View File
@@ -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,
};
+3 -1
View File
@@ -37,6 +37,7 @@ require (
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/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
@@ -213,7 +214,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
+176
View File
@@ -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
}
+23 -17
View File
@@ -253,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 {
@@ -279,11 +285,11 @@ 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
} }
} }
@@ -292,19 +298,19 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
// TODO(lhf): remove Unimplemented check when v0.9.0 is released. // TODO(lhf): remove Unimplemented check when v0.9.0 is released.
if err != nil { if err != nil {
if status.Convert(err).Code() != codes.Unimplemented { if status.Convert(err).Code() != codes.Unimplemented {
return nil, fmt.Errorf("check machine prerequisites: %w", err) return nil, nil, fmt.Errorf("check machine prerequisites: %w", err)
} }
} else if !checkResp.Satisfied { } else if !checkResp.Satisfied {
return nil, fmt.Errorf("machine prerequisites not satisfied: %s", checkResp.Error) 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.
@@ -330,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 {
@@ -351,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 == "").
@@ -367,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
+110 -110
View File
@@ -21,63 +21,6 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
) )
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[0]
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[0]
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{0}
}
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 MachineInfo struct { type MachineInfo struct {
state protoimpl.MessageState state protoimpl.MessageState
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@@ -92,7 +35,7 @@ type MachineInfo struct {
func (x *MachineInfo) Reset() { func (x *MachineInfo) Reset() {
*x = MachineInfo{} *x = MachineInfo{}
if protoimpl.UnsafeEnabled { if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[1] mi := &file_internal_machine_api_pb_machine_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -105,7 +48,7 @@ func (x *MachineInfo) String() string {
func (*MachineInfo) ProtoMessage() {} func (*MachineInfo) ProtoMessage() {}
func (x *MachineInfo) ProtoReflect() protoreflect.Message { func (x *MachineInfo) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[1] mi := &file_internal_machine_api_pb_machine_proto_msgTypes[0]
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 {
@@ -118,7 +61,7 @@ func (x *MachineInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use MachineInfo.ProtoReflect.Descriptor instead. // Deprecated: Use MachineInfo.ProtoReflect.Descriptor instead.
func (*MachineInfo) Descriptor() ([]byte, []int) { func (*MachineInfo) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{1} return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{0}
} }
func (x *MachineInfo) GetId() string { func (x *MachineInfo) GetId() string {
@@ -163,7 +106,7 @@ type NetworkConfig struct {
func (x *NetworkConfig) Reset() { func (x *NetworkConfig) Reset() {
*x = NetworkConfig{} *x = NetworkConfig{}
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[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -176,7 +119,7 @@ func (x *NetworkConfig) String() string {
func (*NetworkConfig) ProtoMessage() {} func (*NetworkConfig) ProtoMessage() {}
func (x *NetworkConfig) ProtoReflect() protoreflect.Message { func (x *NetworkConfig) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[2] mi := &file_internal_machine_api_pb_machine_proto_msgTypes[1]
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 {
@@ -189,7 +132,7 @@ func (x *NetworkConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetworkConfig.ProtoReflect.Descriptor instead. // Deprecated: Use NetworkConfig.ProtoReflect.Descriptor instead.
func (*NetworkConfig) Descriptor() ([]byte, []int) { func (*NetworkConfig) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{2} return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{1}
} }
func (x *NetworkConfig) GetSubnet() *IPPrefix { func (x *NetworkConfig) GetSubnet() *IPPrefix {
@@ -220,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
@@ -697,31 +697,31 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x24, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x24, 0x69, 0x6e, 0x74, 0x65, 0x72,
0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f,
0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22,
0x50, 0x0a, 0x1a, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x85, 0x01, 0x0a, 0x0b, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12,
0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12,
0x09, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
0x52, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03,
0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
0x72, 0x22, 0x85, 0x01, 0x0a, 0x0b, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
0x6f, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x6b, 0x12, 0x24, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x04,
0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x08, 0x70,
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x22, 0xae, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x77,
0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4e, 0x65, 0x74, 0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x75, 0x62,
0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x6f, 0x72, 0x6b, 0x12, 0x24, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74,
0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x12, 0x2c, 0x0a, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x69,
0x08, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x22, 0xae, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50,
0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x52, 0x0c, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x70, 0x12, 0x29,
0x75, 0x62, 0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x0a, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
0x69, 0x2e, 0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x09,
0x65, 0x74, 0x12, 0x2c, 0x0a, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62,
0x5f, 0x69, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70,
0x49, 0x50, 0x52, 0x0c, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0x50, 0x0a, 0x1a, 0x43, 0x68, 0x65, 0x63,
0x12, 0x29, 0x0a, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65,
0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66,
0x52, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73,
0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x66, 0x69, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20,
0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0xc3, 0x01, 0x0a, 0x12, 0x49, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xc3, 0x01, 0x0a, 0x12, 0x49,
0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d, 0x65,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e,
@@ -813,9 +813,9 @@ func file_internal_machine_api_pb_machine_proto_rawDescGZIP() []byte {
var file_internal_machine_api_pb_machine_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_internal_machine_api_pb_machine_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_internal_machine_api_pb_machine_proto_goTypes = []any{ var file_internal_machine_api_pb_machine_proto_goTypes = []any{
(*CheckPrerequisitesResponse)(nil), // 0: api.CheckPrerequisitesResponse (*MachineInfo)(nil), // 0: api.MachineInfo
(*MachineInfo)(nil), // 1: api.MachineInfo (*NetworkConfig)(nil), // 1: api.NetworkConfig
(*NetworkConfig)(nil), // 2: api.NetworkConfig (*CheckPrerequisitesResponse)(nil), // 2: api.CheckPrerequisitesResponse
(*InitClusterRequest)(nil), // 3: api.InitClusterRequest (*InitClusterRequest)(nil), // 3: api.InitClusterRequest
(*InitClusterResponse)(nil), // 4: api.InitClusterResponse (*InitClusterResponse)(nil), // 4: api.InitClusterResponse
(*JoinClusterRequest)(nil), // 5: api.JoinClusterRequest (*JoinClusterRequest)(nil), // 5: api.JoinClusterRequest
@@ -830,16 +830,16 @@ var file_internal_machine_api_pb_machine_proto_goTypes = []any{
(*emptypb.Empty)(nil), // 14: google.protobuf.Empty (*emptypb.Empty)(nil), // 14: google.protobuf.Empty
} }
var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{ var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
2, // 0: api.MachineInfo.network:type_name -> api.NetworkConfig 1, // 0: api.MachineInfo.network:type_name -> api.NetworkConfig
11, // 1: api.MachineInfo.public_ip:type_name -> api.IP 11, // 1: api.MachineInfo.public_ip:type_name -> api.IP
12, // 2: api.NetworkConfig.subnet:type_name -> api.IPPrefix 12, // 2: api.NetworkConfig.subnet:type_name -> api.IPPrefix
11, // 3: api.NetworkConfig.management_ip:type_name -> api.IP 11, // 3: api.NetworkConfig.management_ip:type_name -> api.IP
13, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort 13, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort
12, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix 12, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix
11, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP 11, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP
1, // 7: api.InitClusterResponse.machine:type_name -> api.MachineInfo 0, // 7: api.InitClusterResponse.machine:type_name -> api.MachineInfo
1, // 8: api.JoinClusterRequest.machine:type_name -> api.MachineInfo 0, // 8: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
1, // 9: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo 0, // 9: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
10, // 10: api.Service.containers:type_name -> api.Service.Container 10, // 10: api.Service.containers:type_name -> api.Service.Container
7, // 11: api.InspectServiceResponse.service:type_name -> api.Service 7, // 11: api.InspectServiceResponse.service:type_name -> api.Service
14, // 12: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty 14, // 12: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
@@ -848,11 +848,11 @@ var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
14, // 15: api.Machine.Token:input_type -> google.protobuf.Empty 14, // 15: api.Machine.Token:input_type -> google.protobuf.Empty
14, // 16: api.Machine.Inspect:input_type -> google.protobuf.Empty 14, // 16: api.Machine.Inspect:input_type -> google.protobuf.Empty
8, // 17: api.Machine.InspectService:input_type -> api.InspectServiceRequest 8, // 17: api.Machine.InspectService:input_type -> api.InspectServiceRequest
0, // 18: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse 2, // 18: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
4, // 19: api.Machine.InitCluster:output_type -> api.InitClusterResponse 4, // 19: api.Machine.InitCluster:output_type -> api.InitClusterResponse
14, // 20: api.Machine.JoinCluster:output_type -> google.protobuf.Empty 14, // 20: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
6, // 21: api.Machine.Token:output_type -> api.TokenResponse 6, // 21: api.Machine.Token:output_type -> api.TokenResponse
1, // 22: api.Machine.Inspect:output_type -> api.MachineInfo 0, // 22: api.Machine.Inspect:output_type -> api.MachineInfo
9, // 23: api.Machine.InspectService:output_type -> api.InspectServiceResponse 9, // 23: api.Machine.InspectService:output_type -> api.InspectServiceResponse
18, // [18:24] is the sub-list for method output_type 18, // [18:24] is the sub-list for method output_type
12, // [12:18] is the sub-list for method input_type 12, // [12:18] is the sub-list for method input_type
@@ -869,18 +869,6 @@ func file_internal_machine_api_pb_machine_proto_init() {
file_internal_machine_api_pb_common_proto_init() file_internal_machine_api_pb_common_proto_init()
if !protoimpl.UnsafeEnabled { if !protoimpl.UnsafeEnabled {
file_internal_machine_api_pb_machine_proto_msgTypes[0].Exporter = func(v any, i int) any { file_internal_machine_api_pb_machine_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*CheckPrerequisitesResponse); 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[1].Exporter = func(v any, i int) any {
switch v := v.(*MachineInfo); i { switch v := v.(*MachineInfo); i {
case 0: case 0:
return &v.state return &v.state
@@ -892,7 +880,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
return nil return nil
} }
} }
file_internal_machine_api_pb_machine_proto_msgTypes[2].Exporter = func(v any, i int) any { file_internal_machine_api_pb_machine_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*NetworkConfig); i { switch v := v.(*NetworkConfig); i {
case 0: case 0:
return &v.state return &v.state
@@ -904,6 +892,18 @@ func file_internal_machine_api_pb_machine_proto_init() {
return nil return nil
} }
} }
file_internal_machine_api_pb_machine_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*CheckPrerequisitesResponse); 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[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.(*InitClusterRequest); i { switch v := v.(*InitClusterRequest); i {
case 0: case 0:
@@ -32,6 +32,7 @@ const (
// //
// 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) 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)
@@ -112,6 +113,7 @@ 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) 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)
@@ -1,4 +1,4 @@
package caddyfile package caddyconfig
import ( import (
"encoding/json" "encoding/json"
@@ -10,6 +10,7 @@ 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"
@@ -64,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.
@@ -72,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),
} }
@@ -112,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,
} }
@@ -1,4 +1,4 @@
package caddyfile package caddyconfig
import ( import (
"strings" "strings"
@@ -25,10 +25,12 @@ func TestGenerateConfig(t *testing.T) {
"handler": "static_response", "handler": "static_response",
"status_code": 200 "status_code": 200
}] }]
}] }],
"logs": {}
}, },
"https": { "https": {
"listen": [":443"] "listen": [":443"],
"logs": {}
} }
} }
}` }`
@@ -60,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"}]
}] }]
}, },
@@ -71,10 +81,12 @@ func TestGenerateConfig(t *testing.T) {
"status_code": 200 "status_code": 200
}] }]
} }
] ],
"logs": {}
}, },
"https": { "https": {
"listen": [":443"] "listen": [":443"],
"logs": {}
} }
} }
}`, }`,
@@ -95,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"}
@@ -109,10 +129,12 @@ func TestGenerateConfig(t *testing.T) {
"status_code": 200 "status_code": 200
}] }]
} }
] ],
"logs": {}
}, },
"https": { "https": {
"listen": [":443"] "listen": [":443"],
"logs": {}
} }
} }
}`, }`,
@@ -136,7 +158,8 @@ func TestGenerateConfig(t *testing.T) {
"status_code": 200 "status_code": 200
}] }]
} }
] ],
"logs": {}
}, },
"https": { "https": {
"listen": [":443"], "listen": [":443"],
@@ -145,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": {}
} }
} }
}`, }`,
@@ -180,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"},
@@ -191,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"},
@@ -206,7 +254,8 @@ func TestGenerateConfig(t *testing.T) {
"status_code": 200 "status_code": 200
}] }]
} }
] ],
"logs": {}
}, },
"https": { "https": {
"listen": [":443"], "listen": [":443"],
@@ -215,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"},
@@ -222,7 +279,8 @@ func TestGenerateConfig(t *testing.T) {
] ]
}] }]
} }
] ],
"logs": {}
} }
} }
}`, }`,
@@ -286,6 +344,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"}]
}] }]
}, },
@@ -297,10 +363,12 @@ func TestGenerateConfig(t *testing.T) {
"status_code": 200 "status_code": 200
}] }]
} }
] ],
"logs": {}
}, },
"https": { "https": {
"listen": [":443"] "listen": [":443"],
"logs": {}
} }
} }
}`, }`,
@@ -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
+6
View File
@@ -345,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())
+2 -2
View File
@@ -21,7 +21,7 @@ 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" "github.com/psviderski/uncloud/internal/machine/dns"
@@ -384,7 +384,7 @@ 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)
} }
+5 -5
View File
@@ -14,7 +14,7 @@ import (
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
"github.com/docker/docker/client" "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/dns"
"github.com/psviderski/uncloud/internal/machine/docker" "github.com/psviderski/uncloud/internal/machine/docker"
@@ -39,7 +39,7 @@ 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
// dnsServer is the embedded internal DNS server for the cluster listening on the machine IP. // dnsServer is the embedded internal DNS server for the cluster listening on the machine IP.
dnsServer *dns.Server dnsServer *dns.Server
@@ -52,7 +52,7 @@ 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, dnsServer *dns.Server,
dnsResolver *dns.ClusterResolver, dnsResolver *dns.ClusterResolver,
) ( ) (
@@ -191,10 +191,10 @@ func (nc *networkController) Run(ctx context.Context) error {
}) })
errGroup.Go(func() error { errGroup.Go(func() error {
slog.Info("Starting Caddyfile controller.") 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
}) })
+2
View File
@@ -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)
+6 -2
View File
@@ -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 {
+13 -1
View File
@@ -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
@@ -193,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)
+11 -4
View File
@@ -30,8 +30,10 @@ const (
PullPolicyNever = "never" PullPolicyNever = "never"
) )
var serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$") var (
var dnsLabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) 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 +106,7 @@ func (s *ServiceSpec) Validate() error {
default: default:
return fmt.Errorf("invalid mode: %q", s.Mode) return fmt.Errorf("invalid mode: %q", s.Mode)
} }
if s.Name != "" { if s.Name != "" {
if len(s.Name) > 63 { if len(s.Name) > 63 {
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name) return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
@@ -146,7 +148,6 @@ func (s *ServiceSpec) Validate() error {
return nil return nil
} }
func (s *ServiceSpec) Clone() ServiceSpec { func (s *ServiceSpec) Clone() ServiceSpec {
spec := *s spec := *s
@@ -200,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
} }
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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
-1
View File
@@ -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)
+165
View File
@@ -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
+121 -55
View File
@@ -3,9 +3,9 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Deploy and scale Docker apps on your infrastructure without Kubernetes | 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"/>
@@ -14,11 +14,11 @@
<!-- OpenGraph tags for social media --> <!-- OpenGraph tags for social media -->
<meta property="og:title" <meta property="og:title"
content="Deploy and scale Docker apps on your infrastructure without Kubernetes | Uncloud"/> 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"/>
@@ -27,11 +27,11 @@
<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" <meta name="twitter:title"
content="Deploy and scale Docker apps on your infrastructure without Kubernetes | Uncloud"> 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,6 +54,17 @@
<!-- Navigation links --> <!-- Navigation links -->
<nav class="flex grow"> <nav class="flex grow">
<ul class="flex grow justify-end flex-wrap items-center"> <ul class="flex grow justify-end flex-wrap items-center">
<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://docs.uncloud.run">
<svg class="w-4 h-4 mr-1 fill-current hidden sm:inline"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
</svg>
<span class="ml-1">Docs</span>
</a>
</li>
<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://discord.gg/eR35KQJhPu"> href="https://discord.gg/eR35KQJhPu">
@@ -98,18 +109,18 @@
<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">
Keep your infrastructure simple<br> Self-host and scale web apps<br>
Deploy and manage containers with joy 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 and scaling web apps on your Take your Docker Compose apps to production with zero-downtime deployments, automatic
servers — from cloud VMs to bare metal. No Kubernetes required. HTTPS, and cross-machine scaling. 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"> href="https://github.com/psviderski/uncloud/?tab=readme-ov-file#-quick-start">
Quick Start Deploy in 2 minutes
</a> </a>
</div> </div>
<div> <div>
@@ -634,50 +645,105 @@
<!-- 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 flex-col items-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">
in Australia <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> </div>
<!-- Social links --> <!-- 2nd block - Documentation -->
<ul class="flex space-x-4 mt-4 mb-1"> <div class="sm:col-span-6 md:col-span-3 lg:col-span-2">
<li>
<a class="flex justify-center items-center text-zinc-700 hover:text-zinc-900 transition" <h6 class="text-sm text-zinc-800 font-medium mb-2">Documentation</h6>
href="https://discord.gg/eR35KQJhPu" aria-label="Discord"> <ul class="text-sm space-y-2">
<svg class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" <li>
height="20"> <a class="text-zinc-500 hover:text-zinc-900 transition"
<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"/> href="https://docs.uncloud.run/#use-cases">Use cases</a>
</svg> </li>
</a> <li>
</li> <a class="text-zinc-500 hover:text-zinc-900 transition"
<li> href="https://docs.uncloud.run/getting-started/quick-start">Quick start</a>
<a class="flex justify-center items-center text-zinc-700 hover:text-zinc-900 transition" </li>
href="https://x.com/psviderski" aria-label="Twitter"> </ul>
<svg class="fill-current" xmlns="http://www.w3.org/2000/svg" width="20" height="20"> </div>
<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> <!-- 3rd block - Resources -->
</a> <div class="sm:col-span-6 md:col-span-3 lg:col-span-2">
</li> <h6 class="text-sm text-zinc-800 font-medium mb-2">Resources</h6>
<li> <ul class="text-sm space-y-2">
<a class="flex justify-center items-center text-zinc-700 hover:text-zinc-900 transition" <li>
href="https://t.me/spyyy" aria-label="Telegram"> <a class="text-zinc-500 hover:text-zinc-900 transition"
<svg class="fill-current" xmlns="http://www.w3.org/2000/svg" width="20" height="20"> href="https://github.com/psviderski/uncloud">GitHub project</a>
<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"/> </li>
</svg> <li>
</a> <a class="text-zinc-500 hover:text-zinc-900 transition"
</li> href="https://github.com/psviderski/uncloud/releases">Releases</a>
<li> </li>
<a class="flex justify-center items-center text-zinc-700 hover:text-zinc-900 transition" <li>
href="https://github.com/psviderski/uncloud" aria-label="Telegram"> <a class="text-zinc-500 hover:text-zinc-900 transition"
<svg class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" href="https://github.com/psviderski/uncloud/blob/main/LICENSE">License</a>
height="20"> </li>
<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"/> <li>
</svg> <a class="text-zinc-500 hover:text-zinc-900 transition"
</a> href="https://discord.gg/eR35KQJhPu">Community</a>
</li> </li>
</ul> </ul>
</div>
</div> </div>
</div> </div>
</footer> </footer>
+34
View File
@@ -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;
} }
@@ -2242,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;
} }
@@ -2255,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));
} }
@@ -2302,6 +2320,14 @@ input[type="search"]::-webkit-search-results-decoration {
top: 1.5rem; top: 1.5rem;
} }
.md\:col-span-3{
grid-column: span 3 / span 3;
}
.md\:col-span-6{
grid-column: span 6 / span 6;
}
.md\:mb-0{ .md\:mb-0{
margin-bottom: 0px; margin-bottom: 0px;
} }
@@ -2354,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;
} }