Compare commits

..
88 Commits
Author SHA1 Message Date
Felix HummelandGitHub ac56754281 docs(install): mise installation (#421) 2026-08-21 15:14:02 +10:00
b7e224a1ef feat(compose): support stdin_open and tty (#419)
* feat: support stdin_open and tty

This can be useful to leave a container running without specifying a
command like `sleep`, and probably in other situations as well.

Signed-off-by: Miek Gieben <miek@miek.nl>

* Complete full spec test case

Signed-off-by: Miek Gieben <miek@miek.nl>

* fix container log streaming for containers with TTY

---------

Signed-off-by: Miek Gieben <miek@miek.nl>
Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-07-30 21:50:54 +10:00
Pasha Sviderski 351698c280 fix(cli): completion with direct connections (--connect, --context, --uncloud-config) (fixes #377) 2026-07-21 17:01:21 +10:00
Pasha Sviderski fa77edf53e fix(proxy): don't shutdown 'uc proxy' when a client connection aborts 2026-07-21 14:16:46 +10:00
Pasha Sviderski b99abb7c39 website: update docs social card and favicons 2026-07-14 12:54:44 +10:00
Pasha Sviderski c2ae11a293 docs(cluster): add section about SSH user permissions when connection to the cluster 2026-07-14 12:54:44 +10:00
Pasha Sviderski f1555259de chore(docs): update command outputs in Deploy demo app doc, add section to view logs 2026-07-14 12:54:44 +10:00
Pasha Sviderski 50f8fbcfda fix(install): correctly compare installed nightly daemon version to upgrade to latest 2026-07-14 12:54:44 +10:00
Pasha Sviderski 73f29092ff docs: update use cases on the Overview page 2026-07-14 12:54:44 +10:00
Pasha Sviderski 08b24af341 website: split newsletter subscription into a separate section 2026-07-14 12:54:43 +10:00
Pasha Sviderski b9c54f1ff5 chore(website): configure ingress Caddy as trusted proxy to forward client IPs 2026-07-14 12:54:43 +10:00
Pasha Sviderski 4d76dd601c website: refine faq open source question, update og image 2026-07-14 12:54:43 +10:00
Dario GriffoandGitHub 3af9936d27 docs(install): update Debian repository domain (#413)
The community repository moved from debian.griffo.io to deb.griffo.io
(Debian trademark policy; old domain redirects permanently). Also use
the keyring + signed-by layout and add the missing sudo and apt update.
2026-07-14 12:54:29 +10:00
Pasha Sviderski dc721e511e fix(daemon): extend systemd service start timeout when pulling Docker image for corrosion service 2026-07-10 20:42:27 +10:00
Pasha Sviderski 054b3b8fd4 website: change CTA link to getting started docs 2026-07-09 19:07:25 +10:00
Pasha Sviderski b3896ff642 website: minor 2026-07-09 18:56:46 +10:00
Pasha Sviderski 5b2823de1e website: update meta/og tags and images 2026-07-09 18:56:46 +10:00
Pasha Sviderski 0007f76d09 website: serve hub as /hub 2026-07-09 18:56:46 +10:00
Pasha Sviderski f8e6d1caaa website: send Hub early access form submission to posthog 2026-07-09 18:56:46 +10:00
Pasha Sviderski e684795aee website: refine styles for index and hub 2026-07-09 18:56:46 +10:00
Pasha Sviderski 7964273552 chore(experiments): go mod tidy 2026-07-09 18:56:46 +10:00
Pasha Sviderski 4ad4bce29d website: refine FAQ and form on hub page 2026-07-09 18:56:46 +10:00
Pasha Sviderski d670d18810 website: add new Hub page 2026-07-09 18:56:46 +10:00
Pasha Sviderski a1957a156e docs(install): update machine init/add output that doesn't install corrosion systemd service 2026-07-09 18:56:45 +10:00
JiraliteandGitHub 6266e9ec3a chore(nightly-build): prettier warning in description (#410) 2026-07-06 12:07:59 +10:00
Anton OvchinnikovandGitHub 7c4fcde88d feat(cli): support env var UNCLOUD_DAEMON_VERSION to specify daemon version for "uc machine add/init" (#409) 2026-07-06 10:47:56 +10:00
Nick CampbellandGitHub d36b28c55a fix(docs): version command instead of --version in docs (#408) 2026-07-05 07:17:34 +10:00
Miek GiebenandGitHub 6b41340f1d chore(cli): use cli.Cancelled when cancellikng the command to return non-zero exit code in all cases (#406)
Use `cli.Cancelled` in all cases where we cancel an operation instead of Printf && return nil

Signed-off-by: Miek Gieben <miek@miek.nl>
2026-06-30 19:49:11 +10:00
Pasha Sviderski 4b9ea43402 docs(cli): update machine init/add help with info about installing Docker and uncloudd 2026-06-29 21:03:11 +10:00
Pasha Sviderski e5f23a9189 fix(install): allow to install uc CLI version <0.20 after the artifact renaming 2026-06-26 21:39:39 +10:00
Pasha Sviderski 15e3e32b47 chore(grpc): update min client/server versions to 0.20.0 2026-06-26 20:53:42 +10:00
Pasha Sviderski a5d73fe4d7 refactor(cli): move cmd/uncloud to cmd/uc, change the artifact name uncloud_* -> uc_* 2026-06-26 20:07:15 +10:00
Pasha Sviderski e01c8d3cc7 docs(secrets): add new Concepts/Secrets doc about passing sensitive data to services, including x-command extension 2026-06-26 19:36:42 +10:00
Pasha SviderskiandGitHub 925695a44a feat(secrets): initial support for 'x-command' and 'driver: exec' secrets, referenced as secret://name in environment (#403) 2026-06-26 12:00:16 +10:00
Pasha Sviderski e5b205391e fix(install): install curl on machine init/add if missing (fixes #402) 2026-06-25 10:52:22 +10:00
Pasha Sviderski e9762dda21 chore(api): add TODO to refactor Machine* domain types in api/client packages 2026-06-25 07:13:47 +10:00
Pasha Sviderski 5bfdbd5c8f docs: regenerate CLI reference 2026-06-24 20:09:15 +10:00
Pasha Sviderski 1d5c71730f feat(cli): add JSON output format for 'uc machine ls', remove MACHINE ID column from table output 2026-06-24 20:02:56 +10:00
Pasha Sviderski ad43569fe5 feat(machine): add OS and kernel version information to machine info, show in 'machine ls' 2026-06-24 17:57:20 +10:00
Pasha Sviderski 9ea30e6ee9 refactor(corrosion): reduce transport retry time for corrosion 10s->2s, move some logs to error level 2026-06-24 16:06:00 +10:00
Pasha Sviderski a040ce5f8e chore: make retry log debug 2026-06-24 14:50:45 +10:00
Pasha Sviderski 328d17599b fix(machine): improve context cancellation handling during watch and sync operations 2026-06-24 13:41:14 +10:00
Pasha Sviderski 83256b5bf7 fix(corrosion): handle 404 errors during resubscription and stop retrying 2026-06-24 12:03:45 +10:00
Pasha Sviderski 35191c00e0 fix(cluster): improve error handling on failed machine subscription in cluster store 2026-06-23 20:54:25 +10:00
Pasha Sviderski b08cee03e3 refactor(machine): improve startup and shuitdown logic, shutdown corrosion in one place 2026-06-23 16:03:04 +10:00
Pasha Sviderski 99e1ae0edb fix(corrosion): handle resubscription with from_change=0 that streams rows in new corrosion version 2026-06-23 09:34:45 +10:00
Pasha Sviderski 5f07bc5832 fix(machine): correctly recreate machine in store if missing, improve context handling 2026-06-23 09:29:04 +10:00
Pasha Sviderski 8aa0a96c23 feat(machine ls): add ARCH, VERSION, DOCKER columns in the output table 2026-06-22 19:49:11 +10:00
Pasha Sviderski 8b95db66d1 feat(machine): sync machine arch, daemon and docker versions to store, sync machine info on schedule 2026-06-22 19:47:35 +10:00
Pasha Sviderski 69acf6f58e docs: regenerate CLI reference 2026-06-19 20:55:48 +10:00
Pasha Sviderski 18b4112a33 ucind: use corrosion image tag 2026.6.15 2026-06-19 20:51:59 +10:00
Pasha Sviderski 23d6b4409a machine: make each machine the source of truth for its configuration, sync it to cluster store 2026-06-19 20:51:19 +10:00
Pasha Sviderski 0c42a7df65 cluster: derive default machine name from its hostname when adding to cluster 2026-06-19 16:54:59 +10:00
Pasha Sviderski e3823c29bf cli: strip ANSI sequences (colors, styles) from table output when stdout is not a TTY 2026-06-18 20:29:22 +10:00
Pasha Sviderski 2f717a10a7 cli: do not use interactive TUI elements when control terminal (tty) is not available (fixes #386) 2026-06-18 18:22:48 +10:00
Pasha Sviderski 7bb3cb9682 logs: stream corrosion logs with 'uc machine logs corrosion' from container instead of journal (fixes #392) 2026-06-17 18:28:25 +10:00
Pasha Sviderski 247154eeb3 cli: use forked bubbletea with a fix to drain pending TTY input on shutdown (#286) 2026-06-17 16:32:09 +10:00
Pasha Sviderski 7bac40ae3e client: refactor ListImages to return an error per machine 2026-06-17 16:32:09 +10:00
Pasha Sviderski d36ff67409 unregistry: change the listening port on each machine 5000 -> 51500 2026-06-17 16:32:09 +10:00
Pasha Sviderski 3841da9e1d website: node instead of machine in header, cut subheader 2026-06-17 16:32:09 +10:00
cffa03007b volumes: error when using relative volumes sources in compose (#353)
Signed-off-by: Miek Gieben <miek@miek.nl>
Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-06-17 13:54:43 +10:00
Miek GiebenandGitHub 73d515bb40 cli: add -m/--machine flag completion for 'uc machine logs' (#352)
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-06-17 13:18:53 +10:00
Pasha Sviderski 0c3b8b122f network: add auto-detection of optimal MTU for WireGuard interface, --wg-mtu flag, set max_mtu for Corrosion to 1232 2026-06-16 21:36:12 +10:00
Pasha Sviderski 2f76187bf5 corrosion: use Corrosion 2026.6.15 with fixed API authz for CLI query/exec commands 2026-06-15 16:34:06 +10:00
Pasha Sviderski f275a31096 website: include js in the image 2026-06-12 13:41:32 +10:00
Pasha Sviderski e622c06ca6 website: landing update: Multi-machine Docker Compose for production with terminal examples 2026-06-11 19:07:16 +10:00
Pasha Sviderski fffc6a8b8a website: landing revamp with deploy demo and testimonial 2026-06-10 19:53:28 +10:00
Miek GiebenandGitHub cccb8635e1 ucind: add command aliases rm/delete (#396)
Signed-off-by: Miek Gieben <miek@miek.nl>
2026-06-08 16:34:42 +10:00
1247f961c2 feat: allow to bind to multiple host IP addresses specified as CIDR prefix in x-ports (#358)
Signed-off-by: Miek Gieben <miek@miek.nl>
Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-06-04 20:07:58 +10:00
3586a32987 feat: add 'uc proxy' command to proxy a service port to a local port (#384)
Signed-off-by: Miek Gieben <miek@miek.nl>
Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-06-02 18:56:16 +10:00
24b81af43c feat: add :51090/metrics endpoint with Prometheus metrics listening on machine IP (#304)
Signed-off-by: Miek Gieben <miek@miek.nl>
Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-05-29 18:23:07 +10:00
Miek GiebenandGitHub 11df8e4a75 feat: add 'uc caddy logs' alias for 'uc logs caddy' (#381) 2026-05-28 19:45:39 +10:00
Pasha Sviderski 9424daff28 feat: enable auth for local requests to Corrosion API (resolves #110) 2026-05-26 10:07:12 +10:00
Pasha Sviderski 26951773d1 feat: add created_at/updated_at columns to cluster and machines store tables 2026-05-26 10:07:12 +10:00
Immanuel TikhonovandGitHub 7616f1365a fix: reject extra args in context commands (#376) 2026-05-26 09:22:50 +10:00
Pasha Sviderski 2aaaa70b15 chore: mod tidy 2026-05-25 21:49:04 +10:00
Pasha Sviderski 442b5c62bf refactor: cleanup Corrosion container on reset, move admin socket to runtime dir 2026-05-25 21:41:35 +10:00
Pasha Sviderski 71da32549a refactor: migrate to run Corrosion service as managed container instead of systemd unit, automatically migrate data to v1 2026-05-25 17:15:44 +10:00
Pasha Sviderski 9236aa341a refactor: migrate to latest Corrosion version 2026.5.14 for ucind (compare versions vector for store sync) 2026-05-25 14:09:51 +10:00
Pasha Sviderski 0dd4bc9d36 chore: build a use new corrosion image 2026.5.14 (pointing to v1.0.0 upstream) for ucind 2026-05-22 19:36:07 +10:00
Pasha Sviderski 07acb31bf3 fix: do not include outdated containers from the removed machine in DNS and Caddy configs 2026-05-21 19:33:28 +10:00
Pasha Sviderski a26af3b2ae feat: make machine init/add upgrade installed daemon binary to latest or specified version 2026-05-21 15:58:04 +10:00
Pasha Sviderski 29faeef5a5 docs: regenerate CLI reference 2026-05-21 15:17:49 +10:00
Pasha Sviderski 2f159157cc feat(version): replace --version with 'version' command to print build information for uc and uncloudd 2026-05-21 15:14:08 +10:00
Pasha Sviderski 28c88e4e10 feat(version): collect build-time metadata for uncloud binaries in version pkg 2026-05-21 15:14:08 +10:00
Pasha Sviderski 7f7d091429 chore: make the reset machine prompt a bit clearer 2026-05-21 15:14:08 +10:00
Aaron EcholsandGitHub 424263bdd4 feat: allow to customise WireGuard listen port with --wg-port for machine init/add (#366) 2026-05-19 16:55:55 +10:00
c95136eae6 BREAKING CHANGE: resolve gRPC API proxy targets (machines) on server instead of client, needs upgrade to v0.20 (#247)
Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-05-19 15:22:50 +10:00
228 changed files with 11116 additions and 4765 deletions
+2 -1
View File
@@ -57,7 +57,8 @@ jobs:
The commands below show how to install the nightly version of uncloud.
> **Warning**: These are nightly development builds and may be unstable.
> [!WARNING]
> These are nightly development builds and may be unstable.
**Install uncloud CLI:**
+19 -10
View File
@@ -20,9 +20,9 @@ snapshot:
version_template: "{{ incminor .Version }}-nightly-{{.ShortCommit}}"
builds:
- id: uncloud
main: ./cmd/uncloud
binary: uncloud
- id: uc
main: ./cmd/uc
binary: uc
env:
# Use CGO to be able to build fsevent required by compose on macOS.
- CGO_ENABLED={{ if eq .Os "darwin" }}1{{ else }}0{{ end }}
@@ -34,7 +34,12 @@ builds:
- amd64
- arm64
ldflags:
- -s -w -X github.com/psviderski/uncloud/internal/version.version={{ .Version }}
- -s -w
-X github.com/psviderski/uncloud/internal/version.version={{.Version}}
-X github.com/psviderski/uncloud/internal/version.commit={{.Commit}}
-X github.com/psviderski/uncloud/internal/version.dirty={{.IsGitDirty}}
-X github.com/psviderski/uncloud/internal/version.date={{.CommitDate}}
-X github.com/psviderski/uncloud/internal/version.builtBy=goreleaser
- id: uncloudd
main: ./cmd/uncloudd
@@ -47,12 +52,17 @@ builds:
- amd64
- arm64
ldflags:
- -s -w -X github.com/psviderski/uncloud/internal/version.version={{ .Version }}
- -s -w
-X github.com/psviderski/uncloud/internal/version.version={{.Version}}
-X github.com/psviderski/uncloud/internal/version.commit={{.Commit}}
-X github.com/psviderski/uncloud/internal/version.dirty={{.IsGitDirty}}
-X github.com/psviderski/uncloud/internal/version.date={{.CommitDate}}
-X github.com/psviderski/uncloud/internal/version.builtBy=goreleaser
archives:
- id: uncloud
- id: uc
builds:
- uncloud
- uc
formats:
# gz is not compatible with homebrew releases, so we use tar.gz
- tar.gz
@@ -93,10 +103,9 @@ brews:
description: "Uncloud CLI"
homepage: "https://uncloud.run"
ids:
- uncloud
- uc
install: |
bin.install "uncloud"
bin.install_symlink "uncloud" => "uc"
bin.install "uc"
skip_upload: false
repository:
owner: psviderski
+3 -3
View File
@@ -72,7 +72,7 @@ management.
- **`cmd/`**: Contains main applications
- `uncloud/`: CLI tool with subcommands for machine, service, volume management
- `uc/`: CLI tool with subcommands for machine, service, volume management
- `uncloudd/`: Daemon that runs on each machine
- `ucind/`: Development cluster management for testing
@@ -131,7 +131,7 @@ github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
```bash
# Build binaries
go build -o uncloud ./cmd/uncloud
go build -o uc ./cmd/uc
go build -o uncloudd ./cmd/uncloudd
```
@@ -239,7 +239,7 @@ uc context use <name> # Switch context
### Important Files to Understand
- `cmd/uncloud/main.go`: CLI entry point and command structure
- `cmd/uc/main.go`: CLI entry point and command structure
- `internal/cli/cli.go`: CLI implementation and configuration
- `internal/machine/machine.go`: Core machine management
- `pkg/api/`: Public API definitions
+9 -5
View File
@@ -18,6 +18,7 @@ RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd ./cmd/uncloudd
FROM alpine:${ALPINE_VERSION} AS corrosion-download
ARG TARGETARCH
ARG CORROSION_VERSION
RUN CORROSION_ARCH=$(case "${TARGETARCH}" in \
"amd64") echo "x86_64" ;; \
@@ -25,20 +26,21 @@ RUN CORROSION_ARCH=$(case "${TARGETARCH}" in \
*) 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" \
"https://github.com/psviderski/corrosion/releases/download/v${CORROSION_VERSION}/corrosion-${CORROSION_ARCH}-unknown-linux-gnu.tar.gz" \
&& tar -xzf /tmp/corrosion.tar.gz -C /tmp \
&& install /tmp/corrosion /usr/local/bin/corrosion \
&& install /tmp/corrosion /usr/bin/corrosion \
&& rm /tmp/corrosion.tar.gz /tmp/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
FROM gcr.io/distroless/cc-debian13:latest AS corrosion
COPY --from=corrosion-download /usr/bin/corrosion /usr/bin/corrosion
CMD ["corrosion", "agent"]
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
ARG CORROSION_VERSION
ARG CORROSION_IMAGE="ghcr.io/unlabs-dev/corrosion:${CORROSION_VERSION}"
ARG TARGETOS
ARG TARGETARCH
@@ -47,6 +49,8 @@ RUN crane pull --platform ${TARGETOS}/${TARGETARCH} "${CORROSION_IMAGE}" /corros
# Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker.
FROM docker:29.4.0-dind AS ucind
ARG CORROSION_VERSION
# Create system group and user 'uncloud'.
RUN addgroup -S uncloud && adduser -SHD -h /nonexistent -G uncloud -g "" uncloud
RUN apk --no-cache add \
+2 -2
View File
@@ -32,13 +32,13 @@ available outside the project, so they don't interfere with system packages.
Build the CLI:
```shell
go build -o uc ./cmd/uncloud
go build -o uc ./cmd/uc
```
Or build and run the CLI with a single command:
```shell
go run ./cmd/uncloud --help
go run ./cmd/uc --help
```
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
+6 -4
View File
@@ -1,6 +1,7 @@
# TODO: Makefile is deprecated, add new targets as mise tasks in mise.toml instead.
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
CORROSION_VERSION ?= 2026.6.15
CORROSION_IMAGE = ghcr.io/unlabs-dev/corrosion:$(CORROSION_VERSION)
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
.PHONY: ucind-cluster
@@ -9,11 +10,12 @@ ucind-cluster:
.PHONY: corrosion-image
corrosion-image:
docker build -t "$(CORROSION_IMAGE)" --target corrosion .
docker build --build-arg CORROSION_VERSION=$(CORROSION_VERSION) -t "$(CORROSION_IMAGE)" --target corrosion .
.PHONY: corrosion-multiarch-image-push
corrosion-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
docker buildx build --build-arg CORROSION_VERSION=$(CORROSION_VERSION) --push --platform linux/amd64,linux/arm64 \
-t "$(CORROSION_IMAGE)" --target corrosion .
.PHONY: ucind-multiarch-image-push
ucind-multiarch-image-push:
@@ -70,4 +72,4 @@ _lint:
.PHONY: cli-docs
cli-docs:
go run ./cmd/uncloud docs
go run ./cmd/uc docs
+8 -15
View File
@@ -154,7 +154,6 @@ Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
```bash
$ uc machine init --name oracle-vm ubuntu@152.67.101.197
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
⏳ Running Uncloud install script...
✓ Docker is already installed.
⏳ Installing Docker...
@@ -169,9 +168,6 @@ Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski
✓ 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-aarch64-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! 🎉
@@ -190,21 +186,20 @@ DNS records updated to use only the internet-reachable machines running caddy se
*.xuw3xd.cluster.uncloud.run A → 152.67.101.197
```
1. The CLI SSHs into the machine and installs Docker, the `uncloudd` machine daemon and
[corrosion](https://github.com/superfly/corrosion) service, managed by systemd.
1. The CLI SSHs into the machine and installs Docker and the `uncloudd` machine daemon, managed by systemd.
2. Generates a unique WireGuard key pair, allocates a dedicated subnet `10.210.0.0/24` for the machine and its
containers, and configures `uncloudd` accordingly. All subsequent communication happens with `uncloudd`
through its gRPC API over SSH.
3. Configures and starts `corrosion`, a CRDT-based distributed SQLite database to share cluster state between machines.
3. Configures and starts [corrosion](https://github.com/superfly/corrosion), a CRDT-based distributed SQLite database to
share cluster state between machines, as a Docker container managed by `uncloudd`.
4. Creates a Docker bridge network connected to the WireGuard interface.
5. This machine becomes an entry point for the newly created cluster which is stored in the cluster config under
`~/.config/uncloud` on your local machine.
5. This machine becomes an entry point for the newly created cluster. Its address is stored as a connection info in the
cluster config at `~/.config/uncloud/config.yaml` on your local machine.
**When you add another machine:**
```bash
$ uc machine add --name hetzner-server root@5.223.45.199
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
⏳ Running Uncloud install script...
✓ Docker is already installed.
✓ Linux user and group 'uncloud' created.
@@ -215,9 +210,6 @@ Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski
✓ 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! 🎉
@@ -246,9 +238,10 @@ hetzner-server Up 10.210.1.1/24 5.223.45.199 5.223.45.199:51820, [2
3. Registers the second machine in the cluster state and exchanges WireGuard keys with the first machine.
4. Both machines establish a WireGuard tunnel between each other, allowing Docker containers connected to the bridge
network to communicate directly across machines.
5. Configures and starts `corrosion` on the second machine to sync the cluster state.
5. Configures and starts the `uncloud-corrosion` container on the second machine to sync the cluster state.
6. The second machine is added as an alternative entry point in the cluster config.
7. If one of the machines goes offline, the other machine can still serve cluster operations.
7. If one of the machines goes offline, the other machine can still be used by `uc` to connect and run cluster
operations.
If one more machine is added, the process repeats with a new subnet. The new machine needs to establish a WireGuard
connection with only one of the existing machines. Other machines will learn about it through the shared cluster state
+4
View File
@@ -0,0 +1,4 @@
_ _ ___
| | | |/ __|
| |_| | (__
\__,_|\___|
@@ -48,10 +48,7 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
if opts.machine != "" {
// If a specific machine is requested, use it to get the Caddy configuration.
ctx, _, err = clusterClient.ProxyMachinesContext(ctx, []string{opts.machine})
if err != nil {
return err
}
ctx = clusterClient.ProxySingleMachineContext(ctx, opts.machine)
}
config, err := clusterClient.Caddy.GetConfig(ctx, nil)
+29
View File
@@ -0,0 +1,29 @@
package caddy
import (
"github.com/psviderski/uncloud/cmd/uc/service"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/logs"
"github.com/spf13/cobra"
)
func NewLogsCommand() *cobra.Command {
var options logs.Options
cmd := &cobra.Command{
Use: "logs",
Aliases: []string{"log"},
Short: "View caddy logs.",
Long: `View caddy logs.
This calls "uc logs caddy", see "uc logs" for the documention.
`,
RunE: func(cmd *cobra.Command, args []string) error {
args = append([]string{"caddy"}, args...)
uncli := cmd.Context().Value("cli").(*cli.CLI)
return service.RunLogs(cmd.Context(), uncli, args, options)
},
}
cmd.Flags().AddFlagSet(logs.Flags(&options))
return cmd
}
@@ -12,6 +12,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(
NewConfigCommand(),
NewDeployCommand(),
NewLogsCommand(),
)
return cmd
}
+103
View File
@@ -0,0 +1,103 @@
package context
import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
func TestCommandArgsValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
newCmd func() *cobra.Command
args []string
wantErr bool
}{
{
name: "root accepts no args",
newCmd: NewRootCommand,
args: nil,
wantErr: false,
},
{
name: "root rejects extra args",
newCmd: NewRootCommand,
args: []string{"extra"},
wantErr: true,
},
{
name: "list accepts no args",
newCmd: NewListCommand,
args: nil,
wantErr: false,
},
{
name: "list rejects extra args",
newCmd: NewListCommand,
args: []string{"extra"},
wantErr: true,
},
{
name: "show accepts no args",
newCmd: NewShowCommand,
args: nil,
wantErr: false,
},
{
name: "show rejects extra args",
newCmd: NewShowCommand,
args: []string{"extra"},
wantErr: true,
},
{
name: "connection accepts no args",
newCmd: NewConnectionCommand,
args: nil,
wantErr: false,
},
{
name: "connection rejects extra args",
newCmd: NewConnectionCommand,
args: []string{"extra"},
wantErr: true,
},
{
name: "use accepts no args",
newCmd: NewUseCommand,
args: nil,
wantErr: false,
},
{
name: "use accepts one arg",
newCmd: NewUseCommand,
args: []string{"prod"},
wantErr: false,
},
{
name: "use rejects extra args",
newCmd: NewUseCommand,
args: []string{"prod", "extra"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := tt.newCmd()
require.NotNil(t, cmd.Args)
err := cmd.Args(cmd, tt.args)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
@@ -14,6 +14,7 @@ func NewConnectionCommand() *cobra.Command {
Use: "connection",
Aliases: []string{"conn"},
Short: "Choose a new default connection for the current context.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return selectConnection(uncli)
@@ -5,6 +5,7 @@ import (
"maps"
"slices"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra"
@@ -15,6 +16,7 @@ func NewListCommand() *cobra.Command {
Use: "ls",
Aliases: []string{"list"},
Short: "List available cluster contexts.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(uncli)
@@ -49,6 +51,6 @@ func list(uncli *cli.CLI) error {
t.Row(name, current, fmt.Sprintf("%d", connCount))
}
fmt.Println(t)
lipgloss.Println(t)
return nil
}
@@ -10,6 +10,7 @@ func NewRootCommand() *cobra.Command {
Use: "ctx",
Aliases: []string{"context"},
Short: "Switch between different cluster contexts. Contains subcommands to manage contexts.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return selectContext(uncli)
@@ -11,6 +11,7 @@ func NewShowCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "show",
Short: "Show current cluster context.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return show(uncli)
@@ -8,6 +8,7 @@ import (
"charm.land/huh/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra"
)
@@ -17,6 +18,7 @@ func NewUseCommand() *cobra.Command {
Short: "Switch to a different cluster context.",
Long: "Switch to a different cluster context. If no context is provided, " +
"a list of available contexts will be displayed for selection.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
@@ -49,6 +51,10 @@ func selectContext(uncli *cli.CLI) error {
if len(uncli.Config.Contexts) == 0 {
return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path())
}
if !tui.IsTerminalAvailable() {
return fmt.Errorf("cannot select a context interactively without a terminal. " +
"Pass the context name explicitly: uc ctx use CONTEXT")
}
contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
+9 -1
View File
@@ -163,6 +163,14 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
fmt.Println()
}
// Resolve 'secret://name' references to actual secret values before creating a deployment.
if compose.HasCommandSecretRefs(project) {
fmt.Fprintln(os.Stderr, "Resolving secrets...")
}
if err = compose.ResolveSecrets(ctx, project); err != nil {
return fmt.Errorf("resolve secrets: %w", err)
}
strategy := &deploy.RollingStrategy{
ForceRecreate: opts.recreate,
SkipHealthMonitor: opts.skipHealth,
@@ -203,7 +211,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
// Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
if !opts.yes {
if !tui.IsStdinTerminal() {
if !tui.IsTerminalAvailable() {
return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
}
@@ -5,7 +5,7 @@ import (
"errors"
"fmt"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uc/caddy"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
+8 -18
View File
@@ -88,19 +88,6 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
}
defer clusterClient.Close()
// Get all machines to create ID to name mapping.
allMachines, err := clusterClient.ListMachines(ctx, nil)
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machineIDToName := make(map[string]string)
for _, machineMember := range allMachines {
if machineMember.Machine != nil && machineMember.Machine.Id != "" && machineMember.Machine.Name != "" {
machineIDToName[machineMember.Machine.Id] = machineMember.Machine.Name
}
}
machines := cli.ExpandCommaSeparatedValues(opts.machines)
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
@@ -115,12 +102,15 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
var rows []imageRow
for _, machineImages := range clusterImages {
// Get machine name for better readability.
machineName := machineImages.Metadata.Machine
if m := allMachines.FindByNameOrID(machineName); m != nil {
machineName = m.Machine.Name
if err := machineImages.Error(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
machineImages.Metadata.MachineName, err))
continue
}
// Get machine name for better readability.
machineName := machineImages.Metadata.MachineName
store := "docker"
if machineImages.ContainerdStore {
store = "containerd"
@@ -189,7 +179,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
})
// Print the images in a table format.
fmt.Println(formatImageTable(rows))
lipgloss.Println(formatImageTable(rows))
return nil
}
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"strings"
"github.com/psviderski/uncloud/cmd/uncloud/image"
"github.com/psviderski/uncloud/cmd/uc/image"
"github.com/spf13/cobra"
)
@@ -8,10 +8,9 @@ import (
"strings"
"time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uc/caddy"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui"
@@ -29,6 +28,8 @@ type addOptions struct {
sshKey string
version string
wgEndpoints []string
wgPort int
wgMTU int
yes bool
}
@@ -39,13 +40,19 @@ func NewAddCommand() *cobra.Command {
Short: "Add a remote machine to a cluster.",
Long: `Add a new machine to an existing Uncloud cluster.
By default, it installs Docker and the Uncloud daemon on the machine over SSH unless --no-install
is specified, which assumes they are already installed and running.
Connection methods:
[ssh://]user@host - Use system 'ssh' command with full SSH config support (default, no prefix required)
ssh+go://user@host - Use Go's built-in SSH library`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
PreRunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
cli.BindEnvToFlag(cmd, "version", "UNCLOUD_DAEMON_VERSION")
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
// Determine connection mode and strip scheme.
@@ -70,14 +77,15 @@ Connection methods:
return add(cmd.Context(), uncli, remoteMachine, opts)
},
}
cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Assign a name to the machine.")
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
"Assign a name to the machine. (default is the machine's hostname)")
cmd.Flags().BoolVar(
&opts.noCaddy, "no-caddy", false,
"Don't deploy Caddy reverse proxy service to the machine.",
)
cmd.Flags().BoolVar(
&opts.noInstall, "no-install", false,
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
"Skip installation of Docker and the Uncloud daemon on the machine. "+
"Assumes they're already installed and running.",
)
cmd.Flags().StringVar(
@@ -92,17 +100,25 @@ Connection methods:
)
cmd.Flags().StringVar(
&opts.version, "version", "latest",
"Version of the Uncloud daemon to install on the machine.",
"Version of the Uncloud daemon to install on the machine. [$UNCLOUD_DAEMON_VERSION]",
)
cmd.Flags().StringSliceVar(
&opts.wgEndpoints, "wg-endpoint", nil,
fmt.Sprintf("WireGuard endpoint address that other machines in the cluster should use to establish "+
"WireGuard endpoint address that other machines in the cluster should use to establish "+
"WireGuard connections\n"+
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is %d if omitted.\n", network.WireGuardPort)+
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is the value of --wg-port if omitted.\n"+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
"Defaults to the auto-detected public and routable machine IPs.",
)
cmd.Flags().IntVar(
&opts.wgMTU, "wg-mtu", 0,
"MTU of the WireGuard network interface on the machine. (default auto-detects the optimal value)",
)
cmd.Flags().IntVar(
&opts.wgPort, "wg-port", network.DefaultWireGuardPort,
"UDP port WireGuard listens on for incoming connections from other machines.",
)
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
@@ -125,17 +141,26 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
publicIP = &ip
}
if opts.wgPort < 1 || opts.wgPort > 65535 {
return fmt.Errorf("invalid WireGuard port %d: must be between 1 and 65535", opts.wgPort)
}
if opts.wgMTU != 0 && (opts.wgMTU < network.MinWireGuardMTU || opts.wgMTU > 65535) {
return fmt.Errorf("invalid WireGuard MTU %d: must be 0 (auto-detect) or between %d and 65535",
opts.wgMTU, network.MinWireGuardMTU)
}
addOpts := cli.AddMachineOptions{
MachineName: opts.name,
PublicIP: publicIP,
RemoteMachine: remoteMachine,
SkipInstall: opts.noInstall,
Version: opts.version,
WireguardMTU: opts.wgMTU,
WireguardPort: opts.wgPort,
AutoConfirm: opts.yes,
}
if len(opts.wgEndpoints) > 0 {
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
endpoints, err := cli.ParseWireGuardEndpoints(expanded, uint16(opts.wgPort))
if err != nil {
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
}
@@ -154,19 +179,9 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
}
// Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service.
err = spinner.New().
Title(" Waiting for the machine to join the cluster...").
Type(spinner.MiniDot).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
}).
Run()
err = tui.RunSpinner(ctx, "Waiting for the machine to join the cluster...", func(ctx context.Context) error {
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
})
if err != nil {
return fmt.Errorf("wait for machine to join the cluster: %w", err)
}
+72
View File
@@ -0,0 +1,72 @@
package machine
import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// runVersionFlagEnvTest builds the given command for each case, stubs out RunE so only flag
// parsing and PreRunE run (no SSH calls), and asserts the resolved --version flag value.
// Note: cannot use t.Parallel() because subtests use t.Setenv().
func runVersionFlagEnvTest(t *testing.T, newCommand func() *cobra.Command) {
t.Helper()
tests := []struct {
name string
args []string
env string
want string
}{
{
name: "env var sets version when flag not passed",
args: []string{"root@localhost"},
env: "v1.2.3",
want: "v1.2.3",
},
{
name: "explicit flag wins over env var",
args: []string{"root@localhost", "--version", "v9.9.9"},
env: "v1.2.3",
want: "v9.9.9",
},
{
name: "defaults to latest when neither flag nor env var set",
args: []string{"root@localhost"},
want: "latest",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
t.Setenv("UNCLOUD_DAEMON_VERSION", tt.env)
}
cmd := newCommand()
cmd.RunE = func(*cobra.Command, []string) error {
return nil
}
cmd.SetArgs(tt.args)
require.NoError(t, cmd.Execute())
version, err := cmd.Flags().GetString("version")
require.NoError(t, err)
assert.Equal(t, tt.want, version)
})
}
}
// TestInitCommandVersionFlagEnvVar verifies that the UNCLOUD_DAEMON_VERSION environment variable
// is bound to the --version flag of 'machine init', and that an explicit flag wins.
func TestInitCommandVersionFlagEnvVar(t *testing.T) {
runVersionFlagEnvTest(t, NewInitCommand)
}
// TestAddCommandVersionFlagEnvVar verifies that the UNCLOUD_DAEMON_VERSION environment variable
// is bound to the --version flag of 'machine add', and that an explicit flag wins.
func TestAddCommandVersionFlagEnvVar(t *testing.T) {
runVersionFlagEnvTest(t, NewAddCommand)
}
@@ -7,13 +7,12 @@ import (
"strings"
"time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uncloud/dns"
"github.com/psviderski/uncloud/cmd/uc/caddy"
"github.com/psviderski/uncloud/cmd/uc/dns"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/cluster"
"github.com/psviderski/uncloud/internal/machine/network"
@@ -33,6 +32,8 @@ type initOptions struct {
sshKey string
version string
wgEndpoints []string
wgPort int
wgMTU int
yes bool
}
@@ -44,6 +45,9 @@ func NewInitCommand() *cobra.Command {
Long: `Initialise a new cluster by setting up a remote machine as the first member.
This command creates a new context in your Uncloud config to manage the cluster.
By default, it installs Docker and the Uncloud daemon on the machine over SSH unless --no-install
is specified, which assumes they are already installed and running.
Connection methods:
[ssh://]user@host - Use system 'ssh' command with full SSH config support (default, no prefix required)
ssh+go://user@host - Use Go's built-in SSH library`,
@@ -61,9 +65,12 @@ Connection methods:
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
// TODO: support initialising a cluster on the local machine.
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
PreRunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
cli.BindEnvToFlag(cmd, "version", "UNCLOUD_DAEMON_VERSION")
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
var remoteMachine *cli.RemoteMachine
@@ -100,7 +107,7 @@ Connection methods:
"API endpoint for the Uncloud DNS service.")
cmd.Flags().StringVarP(
&opts.name, "name", "n", "",
"Assign a name to the machine.",
"Assign a name to the machine. (default is the machine's hostname)",
)
cmd.Flags().StringVar(
&opts.network, "network", cluster.DefaultNetwork.String(),
@@ -116,7 +123,7 @@ Connection methods:
)
cmd.Flags().BoolVar(
&opts.noInstall, "no-install", false,
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
"Skip installation of Docker and the Uncloud daemon on the machine. "+
"Assumes they're already installed and running.",
)
cmd.Flags().StringVar(
@@ -131,17 +138,25 @@ Connection methods:
)
cmd.Flags().StringVar(
&opts.version, "version", "latest",
"Version of the Uncloud daemon to install on the machine.",
"Version of the Uncloud daemon to install on the machine. [$UNCLOUD_DAEMON_VERSION]",
)
cmd.Flags().StringSliceVar(
&opts.wgEndpoints, "wg-endpoint", nil,
fmt.Sprintf("WireGuard endpoint address that other machines in the cluster should use to establish "+
"WireGuard endpoint address that other machines in the cluster should use to establish "+
"WireGuard connections\n"+
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is %d if omitted.\n", network.WireGuardPort)+
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is the value of --wg-port if omitted.\n"+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
"Defaults to the auto-detected public and routable machine IPs.",
)
cmd.Flags().IntVar(
&opts.wgMTU, "wg-mtu", 0,
"MTU of the WireGuard network interface on the machine. (default auto-detects the optimal value)",
)
cmd.Flags().IntVar(
&opts.wgPort, "wg-port", network.DefaultWireGuardPort,
"UDP port WireGuard listens on for incoming connections from other machines.",
)
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
@@ -178,6 +193,13 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
}
publicIP = &ip
}
if opts.wgPort < 1 || opts.wgPort > 65535 {
return fmt.Errorf("invalid WireGuard port %d: must be between 1 and 65535", opts.wgPort)
}
if opts.wgMTU != 0 && (opts.wgMTU < network.MinWireGuardMTU || opts.wgMTU > 65535) {
return fmt.Errorf("invalid WireGuard MTU %d: must be 0 (auto-detect) or between %d and 65535",
opts.wgMTU, network.MinWireGuardMTU)
}
initOpts := cli.InitClusterOptions{
Context: opts.context,
MachineName: opts.name,
@@ -186,11 +208,13 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
RemoteMachine: remoteMachine,
SkipInstall: opts.noInstall,
Version: opts.version,
WireguardMTU: opts.wgMTU,
WireguardPort: opts.wgPort,
AutoConfirm: opts.yes,
}
if len(opts.wgEndpoints) > 0 {
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
endpoints, err := cli.ParseWireGuardEndpoints(expanded, uint16(opts.wgPort))
if err != nil {
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
}
@@ -206,19 +230,9 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
// Since the cluster API needs a few moments to become ready after cluster initialisation,
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
err = spinner.New().
Title(" Waiting for the cluster to be ready...").
Type(spinner.MiniDot).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
return client.WaitClusterReady(ctx, 1*time.Minute)
}).
Run()
err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error {
return client.WaitClusterReady(ctx, 1*time.Minute)
})
if err != nil {
return fmt.Errorf("wait for cluster to be ready: %w", err)
}
@@ -3,10 +3,12 @@ package machine
import (
"context"
"fmt"
"slices"
"strings"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/logs"
"github.com/psviderski/uncloud/internal/journal"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra"
@@ -18,14 +20,14 @@ func NewLogsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "logs [SERVICE...]",
Aliases: []string{"log"},
Short: "View systemd service logs.",
Long: `View logs from the specified systemd service(s) across all machines in the cluster.
Short: "View system service logs.",
Long: `View logs from the specified system service(s) across all machines in the cluster.
Use -m to restrict to specific machines.
Supported services:
uncloud the Uncloud daemon
docker the Docker daemon
uncloud-corrosion the Corrosion distributed state store
corrosion the Corrosion distributed state store
docker the Docker daemon
uncloud the Uncloud daemon
If no services are specified, streams logs from the uncloud service.`,
Example: ` # View recent logs for the uncloud service.
@@ -36,7 +38,7 @@ If no services are specified, streams logs from the uncloud service.`,
uc machine logs -f uncloud
# View logs from multiple services.
uc machine logs uncloud docker uncloud-corrosion
uc machine logs uncloud docker corrosion
# Show last 20 lines per machine (default is 100).
uc machine logs -n 20 docker
@@ -48,7 +50,7 @@ If no services are specified, streams logs from the uncloud service.`,
uc machine logs --since 3h --until 1h30m docker
# View logs only from specific machines.
uc machine logs -m machine1,machine2 uncloud uncloud-corrosion`,
uc machine logs -m machine1,machine2 uncloud corrosion`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return runLogs(cmd.Context(), uncli, args, options)
@@ -56,17 +58,19 @@ If no services are specified, streams logs from the uncloud service.`,
}
cmd.Flags().AddFlagSet(logs.Flags(&options))
completion.MachinesFlag(cmd)
return cmd
}
func runLogs(ctx context.Context, uncli *cli.CLI, units []string, opts logs.Options) error {
if len(units) == 0 {
units = []string{journal.UnitUncloud}
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error {
if len(services) == 0 {
services = []string{api.SystemServiceUncloud}
}
for _, unit := range units {
if !journal.ValidUnit(unit) {
return fmt.Errorf("invalid systemd service '%s'", unit)
for _, service := range services {
if !slices.Contains(api.SystemServices, service) {
return fmt.Errorf("invalid system service '%s'; valid services: %s",
service, strings.Join(api.SystemServices, ", "))
}
}
@@ -101,27 +105,27 @@ func runLogs(ctx context.Context, uncli *cli.CLI, units []string, opts logs.Opti
machineNames = append(machineNames, m.Machine.Name)
}
// Collect one log stream per unit. MachineLogs merges across machines internally.
unitStreams := make([]<-chan api.ServiceLogEntry, 0, len(units))
for _, unit := range units {
ch, err := c.MachineLogs(ctx, unit, logsOpts)
// Collect one log stream per service. MachineLogs merges across machines internally.
serviceStreams := make([]<-chan api.ServiceLogEntry, 0, len(services))
for _, service := range services {
ch, err := c.MachineLogs(ctx, service, logsOpts)
if err != nil {
return fmt.Errorf("stream logs for systemd service '%s': %w", unit, err)
return fmt.Errorf("stream logs for system service '%s': %w", service, err)
}
unitStreams = append(unitStreams, ch)
serviceStreams = append(serviceStreams, ch)
}
var stream <-chan api.ServiceLogEntry
if len(unitStreams) == 1 {
stream = unitStreams[0]
if len(serviceStreams) == 1 {
stream = serviceStreams[0]
} else {
// Each MachineLogs stream already runs its own inner merger with stall detection,
// so the outer merger across units skips it to avoid duplicate warnings.
merger := client.NewLogMerger(unitStreams, client.LogMergerOptions{})
// so the outer merger across services skips it to avoid duplicate warnings.
merger := client.NewLogMerger(serviceStreams, client.LogMergerOptions{})
stream = merger.Stream()
}
formatter := logs.NewFormatter(machineNames, units, opts.UTC)
formatter := logs.NewFormatter(machineNames, services, opts.UTC)
// Print merged logs.
for entry := range stream {
@@ -2,10 +2,12 @@ package machine
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"strings"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/network"
@@ -13,19 +15,29 @@ import (
)
func NewListCommand() *cobra.Command {
var output string
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List machines in a cluster.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli)
return list(cmd.Context(), uncli, output)
},
}
cmd.Flags().StringVarP(&output, "output", "o", "",
"Output format: 'json' or empty for a human-readable table.")
return cmd
}
func list(ctx context.Context, uncli *cli.CLI) error {
func list(ctx context.Context, uncli *cli.CLI, output string) error {
if output != "" && output != "json" {
return fmt.Errorf("unsupported output format '%s' (supported: json)", output)
}
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
@@ -37,9 +49,19 @@ func list(ctx context.Context, uncli *cli.CLI) error {
return fmt.Errorf("list machines: %w", err)
}
if output == "json" {
data, err := json.MarshalIndent(machines.ToNative(), "", " ")
if err != nil {
return fmt.Errorf("marshal machines: %w", err)
}
fmt.Println(string(data))
return nil
}
// Print the list of machines in a table format.
t := tui.NewTable()
t.Headers("NAME", "STATE", "ADDRESS", "PUBLIC IP", "WIREGUARD ENDPOINTS", "MACHINE ID")
t.Headers("NAME", "STATE", "ADDRESS", "PUBLIC IP", "WIREGUARD ENDPOINTS",
"OS", "KERNEL", "ARCH", "DOCKER", "VERSION")
for _, member := range machines {
m := member.Machine
@@ -58,17 +80,46 @@ func list(ctx context.Context, uncli *cli.CLI) error {
endpoints[i] = addrPort.String()
}
arch := "-"
if m.Arch != "" {
arch = m.Arch
}
osName := "-"
if m.OsPrettyName != "" {
osName = m.OsPrettyName
}
kernel := "-"
if m.KernelVersion != "" {
kernel = m.KernelVersion
}
daemonVersion := "-"
if m.DaemonVersion != "" {
daemonVersion = m.DaemonVersion
}
dockerVersion := "-"
if m.DockerVersion != "" {
dockerVersion = m.DockerVersion
}
t.Row(
m.Name,
capitalise(member.State.String()),
subnet.String(),
publicIP,
strings.Join(endpoints, tui.Faint.Render(", ")),
member.Machine.Id,
osName,
kernel,
arch,
dockerVersion,
daemonVersion,
)
}
fmt.Println(t)
lipgloss.Println(t)
return nil
}
@@ -63,15 +63,16 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
}
defer client.Close()
// Verify the machine exists and list all service containers on it including stopped ones.
mctx, machines, err := client.ProxyMachinesContext(ctx, []string{nameOrID})
// Verify the machine exists in the cluster.
member, err := client.InspectMachine(ctx, nameOrID)
if err != nil {
return err
return fmt.Errorf("inspect machine '%s': %w", nameOrID, err)
}
if len(machines) == 0 {
return fmt.Errorf("machine '%s' not found in the cluster", nameOrID)
}
m := machines[0].Machine
m := member.Machine
// Create a proxy context for the machine being removed.
// This is used for calls that need to run directly on that machine.
rmCtx := client.ProxySingleMachineContext(ctx, m.Id)
// Verify if the machine being removed is the proxy machine we're connected to.
proxyMachine, err := client.MachineClient.Inspect(ctx, nil)
@@ -103,7 +104,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
if reset {
// Check if the machine is up and has service containers.
listOpts := container.ListOptions{All: true}
machineContainers, err := client.Docker.ListServiceContainers(mctx, "", listOpts)
machineContainers, err := client.Docker.ListServiceContainers(rmCtx, "", listOpts)
if err == nil {
reachable = true
containers = machineContainers[0].Containers
@@ -113,7 +114,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
plural = "s"
}
fmt.Printf("Found %d service container%s on machine '%s':\n", len(containers), plural, m.Name)
fmt.Println(formatContainerTree(containers))
lipgloss.Println(formatContainerTree(containers))
fmt.Println()
fmt.Println("This will remove all service containers from the machine, remove it from the cluster, " +
"and reset it to the uninitialised state.")
@@ -135,8 +136,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
return fmt.Errorf("confirm removal: %w", err)
}
if !confirmed {
fmt.Println("Cancelled. Machine was not removed.")
return nil
return cli.Cancelled("Cancelled. Machine was not removed.")
}
}
@@ -150,20 +150,22 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
fmt.Println()
}
if _, err = client.RemoveMachine(ctx, &pb.RemoveMachineRequest{Id: m.Id}); err != nil {
return fmt.Errorf("remove machine from cluster: %w", err)
}
fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
// Initiate reset before removing the machine from the cluster to stop it from updating the cluster store.
// This is still optimistic as Reset only triggers the reset process that runs asynchronously.
if reset && reachable {
_, err = client.MachineClient.Reset(mctx, &pb.ResetRequest{})
_, err = client.MachineClient.Reset(rmCtx, &pb.ResetRequest{})
if err != nil {
fmt.Printf("WARNING: Failed to reset machine: %v\n", err)
tui.PrintWarning(fmt.Sprintf("Failed to reset machine: %v\n", err))
} else {
fmt.Println("Machine reset initiated and will complete in the background.")
}
}
if _, err = client.RemoveMachine(ctx, &pb.RemoveMachineRequest{Id: m.Id}); err != nil {
return fmt.Errorf("remove machine from cluster: %w", err)
}
fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
// Remove the connection to the machine from the uncloud config if it exists.
if uncli.Config != nil {
contextName := uncli.ContextOverrideOrCurrent()
@@ -6,6 +6,7 @@ import (
"sort"
"time"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra"
@@ -38,10 +39,7 @@ func rtt(ctx context.Context, uncli *cli.CLI) error {
defer client.Close()
// Setup context to proxy request to all machines.
ctx, _, err = client.ProxyMachinesContext(ctx, nil)
if err != nil {
return fmt.Errorf("setup proxy context: %w", err)
}
ctx = client.ProxyMachinesContext(ctx, nil)
resp, err := client.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
if err != nil {
@@ -51,6 +49,15 @@ func rtt(ctx context.Context, uncli *cli.CLI) error {
// Map machine IDs to names for display from the response.
machineNames := make(map[string]string)
for _, m := range resp.Machines {
// NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed.
if m.Metadata == nil {
tui.PrintWarning("metadata is missing in response from unknown server")
continue
}
if m.Metadata.Error != "" {
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, m.Metadata.Error))
continue
}
if m.Machine == nil {
continue
}
@@ -101,7 +108,7 @@ func rtt(ctx context.Context, uncli *cli.CLI) error {
t.Row(r.machine, r.peer, tui.FormatRTT(r.median), formatRTTStdDev(r.stdDev))
}
fmt.Println(t)
lipgloss.Println(t)
return nil
}
@@ -70,7 +70,8 @@ At least one flag must be specified to perform an update.`,
fmt.Sprintf("WireGuard endpoint address that other machines in the cluster should use to establish "+
"WireGuard connections\n"+
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is %d if omitted.\n", network.WireGuardPort)+
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is %d if omitted.\n",
network.DefaultWireGuardPort)+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.",
)
@@ -89,16 +90,13 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
}
defer client.Close()
// First, resolve the machine to get its ID
// Resolve the machine to capture its current configuration for the before/after report and to validate existence.
machine, err := client.InspectMachine(ctx, machineNameOrID)
if err != nil {
return fmt.Errorf("find machine: %w", err)
}
// Build the update request
req := &pb.UpdateMachineRequest{
MachineId: machine.Machine.Id,
}
req := &pb.UpdateMachineRequest{}
if opts.name != "" {
req.Name = &opts.name
@@ -121,7 +119,7 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
// Parse and set endpoints if the flag was explicitly provided.
if cmd.Flags().Changed("wg-endpoint") {
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
endpoints, err := cli.ParseWireGuardEndpoints(expanded, network.DefaultWireGuardPort)
if err != nil {
return err
}
@@ -131,8 +129,7 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
req.Endpoints = endpoints
}
// Perform the update operation
updatedMachine, err := client.UpdateMachine(ctx, req)
updatedMachine, err := client.UpdateMachine(ctx, machine.Machine.Id, req)
if err != nil {
return fmt.Errorf("update machine: %w", err)
}
+45 -16
View File
@@ -8,22 +8,23 @@ import (
"os"
"strings"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
"github.com/psviderski/uncloud/cmd/uncloud/dns"
"github.com/psviderski/uncloud/cmd/uncloud/image"
cmdmachine "github.com/psviderski/uncloud/cmd/uncloud/machine"
"github.com/psviderski/uncloud/cmd/uncloud/service"
"github.com/psviderski/uncloud/cmd/uncloud/volume"
"github.com/psviderski/uncloud/cmd/uncloud/wg"
"github.com/psviderski/uncloud/cmd/uc/caddy"
cmdcontext "github.com/psviderski/uncloud/cmd/uc/context"
"github.com/psviderski/uncloud/cmd/uc/dns"
"github.com/psviderski/uncloud/cmd/uc/image"
cmdmachine "github.com/psviderski/uncloud/cmd/uc/machine"
"github.com/psviderski/uncloud/cmd/uc/service"
"github.com/psviderski/uncloud/cmd/uc/volume"
"github.com/psviderski/uncloud/cmd/uc/wg"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/fs"
"github.com/psviderski/uncloud/internal/log"
"github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/version"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type globalOptions struct {
@@ -39,10 +40,16 @@ func main() {
cmd := &cobra.Command{
Use: "uc",
Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.",
Version: version.String(),
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Shell completion runs through the hidden __complete command which has flag parsing disabled,
// so the global flags from the completed command line are never parsed. Apply them manually to make
// completion work with --connect, --context, and --uncloud-config.
if cmd.Name() == cobra.ShellCompRequestCmd {
applyGlobalFlagsFromCompletionArgs(cmd.Root().PersistentFlags(), os.Args[1:])
}
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
cli.BindEnvToFlag(cmd, "context", "UNCLOUD_CONTEXT")
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
@@ -113,15 +120,11 @@ func main() {
defaultHelpFunc(c, args)
// Only show links for the root 'uc' command.
if c.Name() == "uc" {
urlStyle := lipgloss.NewStyle().
Underline(true).
Foreground(lipgloss.Color("12")) // light blue
fmt.Fprintln(c.OutOrStdout())
fmt.Fprintf(c.OutOrStdout(), "Learn more about Uncloud: %s\n",
urlStyle.Render("https://uncloud.run/docs"))
tui.URLStyle.Render(version.DocsURL))
fmt.Fprintf(c.OutOrStdout(), "Join our Discord community: %s\n",
urlStyle.Render("https://uncloud.run/discord"))
tui.URLStyle.Render(version.DiscordURL))
}
})
@@ -136,6 +139,7 @@ func main() {
NewDocsCommand(),
NewImagesCommand(),
NewPsCommand(),
NewProxyCommand(),
caddy.NewRootCommand(),
cmdcontext.NewRootCommand(),
dns.NewRootCommand(),
@@ -151,6 +155,7 @@ func main() {
service.NewScaleCommand("service"),
service.NewStartCommand("service"),
service.NewStopCommand("service"),
NewVersionCommand(),
volume.NewRootCommand(),
wg.NewRootCommand(),
)
@@ -162,3 +167,27 @@ func main() {
cobra.CheckErr(err)
}
}
// applyGlobalFlagsFromCompletionArgs parses the global flags from the raw arguments of a __complete command and applies
// the ones found to flags. The trailing word being completed, unknown flags, and positional arguments are ignored.
func applyGlobalFlagsFromCompletionArgs(flags *pflag.FlagSet, args []string) {
// The shell always passes the word being completed as the last argument, even if it's empty.
// Exclude it from parsing as its value may not be complete yet.
if len(args) == 0 {
return
}
args = args[:len(args)-1]
fset := pflag.NewFlagSet("global", pflag.ContinueOnError)
fset.ParseErrorsAllowlist.UnknownFlags = true
fset.String("connect", "", "")
fset.StringP("context", "c", "", "")
fset.String("uncloud-config", "", "")
// Parsing an incomplete command line may fail, apply the flags parsed so far anyway.
_ = fset.Parse(args)
fset.Visit(func(f *pflag.Flag) {
// Setting the flag marks it as changed so it takes precedence over environment variables.
_ = flags.Set(f.Name, f.Value.String())
})
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"slices"
"testing"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
)
func TestApplyGlobalFlagsFromCompletionArgs(t *testing.T) {
defaultConfigPath := "~/.config/uncloud/config.yaml"
tests := []struct {
name string
args []string
wantConnect string
wantContext string
wantConfigPath string
// Flag names expected to be marked as changed on the target flag set.
wantChanged []string
}{
{
name: "no flags",
args: []string{"__complete", "inspect", ""},
},
{
name: "connect with space",
args: []string{"__complete", "--connect", "ssh://user@host", "inspect", ""},
wantConnect: "ssh://user@host",
wantChanged: []string{"connect"},
},
{
name: "connect with equals",
args: []string{"__complete", "--connect=tcp://127.0.0.1:51000", "inspect", ""},
wantConnect: "tcp://127.0.0.1:51000",
wantChanged: []string{"connect"},
},
{
name: "context shorthand",
args: []string{"__complete", "-c", "prod", "inspect", ""},
wantContext: "prod",
wantChanged: []string{"context"},
},
{
name: "all flags",
args: []string{"__complete", "--connect", "user@host", "-c", "prod", "--uncloud-config", "/tmp/uncloud.yaml", "inspect", ""},
wantConnect: "user@host",
wantContext: "prod",
wantConfigPath: "/tmp/uncloud.yaml",
wantChanged: []string{"connect", "context", "uncloud-config"},
},
{
name: "unknown flags are ignored",
args: []string{"__complete", "--quiet", "-n", "5", "--connect", "user@host", "logs", ""},
wantConnect: "user@host",
wantChanged: []string{"connect"},
},
{
name: "flags after double dash are ignored",
args: []string{"__complete", "exec", "svc", "--", "sh", "--connect", "user@host"},
},
{
name: "flags before double dash are applied",
args: []string{"__complete", "--connect", "user@host", "exec", "svc", "--", "sh", "-c", "env"},
wantConnect: "user@host",
wantChanged: []string{"connect"},
},
{
name: "partial flag name being completed is excluded",
args: []string{"__complete", "--connect", "user@host", "inspect", "--context"},
wantConnect: "user@host",
wantChanged: []string{"connect"},
},
{
name: "partial flag value being completed is excluded",
args: []string{"__complete", "--uncloud-config", "/tmp/"},
},
{
name: "partial connect value being completed is excluded",
args: []string{"__complete", "--connect", "tcp://127.0.0.1:5"},
},
{
name: "completed flag value with partial command word",
args: []string{"__complete", "--uncloud-config", "/tmp/uncloud.yaml", "insp"},
wantConfigPath: "/tmp/uncloud.yaml",
wantChanged: []string{"uncloud-config"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Mirror the global persistent flags defined on the root command.
var opts globalOptions
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.StringVar(&opts.connect, "connect", "", "")
flags.StringVarP(&opts.context, "context", "c", "", "")
flags.StringVar(&opts.configPath, "uncloud-config", defaultConfigPath, "")
applyGlobalFlagsFromCompletionArgs(flags, tt.args)
assert.Equal(t, tt.wantConnect, opts.connect)
assert.Equal(t, tt.wantContext, opts.context)
wantConfigPath := tt.wantConfigPath
if wantConfigPath == "" {
wantConfigPath = defaultConfigPath
}
assert.Equal(t, wantConfigPath, opts.configPath)
for _, name := range []string{"connect", "context", "uncloud-config"} {
assert.Equal(t, slices.Contains(tt.wantChanged, name), flags.Changed(name),
"changed status of flag '%s'", name)
}
})
}
}
+175
View File
@@ -0,0 +1,175 @@
package main
import (
"context"
"errors"
"fmt"
"net"
"strconv"
"strings"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/proxy"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type proxyOptions struct {
localPort int
remotePort int
service string
}
// NewProxyCommand creates a new command to proxy a local port to a service's port in the cluster.
func NewProxyCommand() *cobra.Command {
opts := proxyOptions{}
cmd := &cobra.Command{
Use: "proxy SERVICE [LOCAL_PORT:]REMOTE_PORT",
Args: cobra.ExactArgs(2),
Short: "Proxy a service port to a local port.",
Long: `Proxy a service port in the cluster to a local port on this machine.
If the service runs multiple containers, the command connects to the first running and healthy one.
If you don't provide a local port, the command picks a random one.
The connection stays open for as long as the command runs.`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0]
parts := strings.Split(args[1], ":")
switch len(parts) {
case 1:
remoteport, err := strconv.Atoi(parts[0])
if err != nil {
return fmt.Errorf("invalid remote port: '%s': %w", parts[0], err)
}
opts.remotePort = remoteport
case 2:
localport, err := strconv.Atoi(parts[0])
if err != nil {
return fmt.Errorf("invalid local port: '%s': %w", parts[0], err)
}
remoteport, err := strconv.Atoi(parts[1])
if err != nil {
return fmt.Errorf("invalid remote port: '%s': %w", parts[1], err)
}
opts.localPort = localport
opts.remotePort = remoteport
default:
return fmt.Errorf("invalid port")
}
return runProxy(cmd.Context(), uncli, opts)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
uncli := cmd.Context().Value("cli").(*cli.CLI)
return completion.Services(cmd.Context(), uncli, args, toComplete)
},
}
return cmd
}
func runProxy(ctx context.Context, uncli *cli.CLI, opts proxyOptions) error {
if opts.localPort < 0 || opts.localPort > 65535 {
return fmt.Errorf("invalid local port %d: must be between 0 and 65535", opts.localPort)
}
if opts.remotePort < 1 || opts.remotePort > 65535 {
return fmt.Errorf("invalid remote port %d: must be between 1 and 65535", opts.remotePort)
}
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
svc, err := clusterClient.InspectService(ctx, opts.service)
if err != nil {
if errors.Is(err, api.ErrNotFound) {
return fmt.Errorf("service '%s' not found in the cluster", opts.service)
}
return fmt.Errorf("inspect service '%s': %w", opts.service, err)
}
// Pick the first running and healthy container to proxy to.
var ctr *api.MachineServiceContainer
for i := range svc.Containers {
if svc.Containers[i].Container.Healthy() {
ctr = &svc.Containers[i]
break
}
}
if ctr == nil {
return fmt.Errorf("no running healthy container found for service '%s'", opts.service)
}
containerID := ctr.Container.ShortID()
ip := ctr.Container.UncloudNetworkIP()
if !ip.IsValid() {
return fmt.Errorf("container '%s' is not connected to the uncloud Docker network (could be host network)",
containerID)
}
dialer, err := clusterClient.Dialer()
if err != nil {
return fmt.Errorf("get proxy dialer: %w", err)
}
listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(opts.localPort)))
if err != nil {
return fmt.Errorf("listen on 127.0.0.1:%d: %w", opts.localPort, err)
}
// There is no precheck if we can connect, as this always succeeds, only the proxy connects with the
// endpoint and shuffles the data, *it* will actually experience errors.
remoteAddr := net.JoinHostPort(ip.String(), strconv.Itoa(opts.remotePort))
p := &proxy.Proxy{
Listener: listener,
RemoteAddr: remoteAddr,
DialContext: dialer.DialContext,
OnError: func(err error) {
if proxy.IsConnectionClosedError(err) {
return
}
// A more actionable error instead of the cryptic [ssh -W] command error.
if strings.Contains(err.Error(), "Session open refused by peer") {
fmt.Printf("Could not connect to '%s': connection refused. "+
"Check that the service is running and listening on port %d inside the container.\n",
remoteAddr, opts.remotePort)
return
}
fmt.Printf("Failed to proxy a connection to '%s': %v\n", remoteAddr, err)
},
}
// Prefix the local address with the scheme for common HTTP ports so it becomes control-clickable in most
// terminals. We assume plain HTTP since TLS is typically terminated by Caddy in front of the service.
fmt.Printf("%s%s → %s (%s%s%s)\n", schemeForPort(opts.remotePort), p.Listener.Addr().String(),
remoteAddr, opts.service, tui.Faint.Render("/"), containerID)
if err = p.Run(ctx); err != nil {
return fmt.Errorf("run proxy to '%s': %w", remoteAddr, err)
}
return nil
}
// schemeForPort returns the "http://" URL scheme prefix for the ports most likely to serve plain HTTP,
// or an empty string otherwise.
func schemeForPort(port int) string {
switch port {
case 80, 3000, 8000, 8080, 8081, 8888, 9090:
return "http://"
default:
return ""
}
}
+14 -48
View File
@@ -6,7 +6,6 @@ import (
"sort"
"time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-units"
@@ -83,20 +82,10 @@ func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
defer clusterClient.Close()
var containers []containerInfo
err = spinner.New().
Title(" Collecting container info...").
Type(spinner.MiniDot).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
containers, err = collectContainers(ctx, clusterClient)
return err
}).
Run()
err = tui.RunSpinner(ctx, "Collecting container info...", func(ctx context.Context) error {
containers, err = collectContainers(ctx, clusterClient)
return err
})
if err != nil {
return fmt.Errorf("collect containers: %w", err)
}
@@ -193,23 +182,12 @@ func printContainers(containers []containerInfo) error {
}
}
fmt.Println(t)
lipgloss.Println(t)
return nil
}
func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo, error) {
listCtx, machines, err := cli.ProxyMachinesContext(ctx, nil)
if err != nil {
return nil, fmt.Errorf("proxy machines context: %w", err)
}
// Create a map of IP to machine name for resolving response metadata
machinesNamesByIP := make(map[string]string)
for _, m := range machines {
if addr, err := m.Machine.Network.ManagementIp.ToAddr(); err == nil {
machinesNamesByIP[addr.String()] = m.Machine.Name
}
}
listCtx := cli.ProxyMachinesContext(ctx, nil)
// List all service containers across all machines in the cluster.
machineContainers, err := cli.Docker.ListServiceContainers(
@@ -221,29 +199,17 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
var containers []containerInfo
for _, msc := range machineContainers {
// Metadata can be nil if the request was broadcasted to only one machine.
if msc.Metadata == nil && len(machineContainers) > 1 {
return nil, fmt.Errorf("something went wrong with gRPC proxy: metadata is missing for a machine response")
// NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed.
if msc.Metadata == nil {
tui.PrintWarning("metadata is missing in response from unknown server")
continue
}
machineName := "unknown"
if msc.Metadata != nil {
var ok bool
machineName, ok = machinesNamesByIP[msc.Metadata.Machine]
if !ok {
// Fallback to machine's IP as name.
machineName = msc.Metadata.Machine
}
} else {
// Fallback to the first available machine name.
if len(machines) > 0 {
machineName = machines[0].Machine.Name
}
}
machineName := msc.Metadata.MachineName
if msc.Metadata != nil && msc.Metadata.Error != "" {
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
msc.Metadata.Error))
if msc.Metadata.Error != "" {
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s",
machineName, msc.Metadata.Error))
continue
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"context"
"encoding/json"
"testing"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/docker"
"github.com/psviderski/uncloud/pkg/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
type mockDockerClient struct {
pb.DockerClient
listResp *pb.ListServiceContainersResponse
listErr error
}
func (m *mockDockerClient) ListServiceContainers(ctx context.Context, in *pb.ListServiceContainersRequest, opts ...grpc.CallOption) (*pb.ListServiceContainersResponse, error) {
return m.listResp, m.listErr
}
func TestCollectContainers(t *testing.T) {
containerData1 := map[string]interface{}{
"Id": "container1",
"Name": "container-1",
"Config": map[string]any{
"Image": "image-1",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]any{
"Id": "container2",
"Name": "container-2",
"Config": map[string]any{
"Image": "image-2",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: &pb.Metadata{MachineAddr: "10.0.0.1", MachineName: "machine-1"},
Containers: []*pb.ServiceContainer{
{
Container: containerJSON1,
ServiceSpec: serviceSpecJSON,
},
},
},
{
Metadata: &pb.Metadata{MachineAddr: "10.0.0.2", MachineName: "machine-2"},
Containers: []*pb.ServiceContainer{
{
Container: containerJSON2,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
cli := &client.Client{
Docker: &docker.Client{GRPCClient: mockDocker},
}
containers, err := collectContainers(context.Background(), cli)
require.NoError(t, err)
assert.Len(t, containers, 2)
for _, c := range containers {
if c.id == "container1" {
assert.Equal(t, "machine-1", c.machineName)
} else if c.id == "container2" {
assert.Equal(t, "machine-2", c.machineName)
} else {
t.Errorf("unexpected container id: %s", c.id)
}
}
}
@@ -6,6 +6,7 @@ import (
"slices"
"time"
"charm.land/lipgloss/v2"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
@@ -54,15 +55,6 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
return fmt.Errorf("inspect service: %w", err)
}
machines, err := client.ListMachines(ctx, nil)
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machinesNamesByID := make(map[string]string)
for _, m := range machines {
machinesNamesByID[m.Machine.Id] = m.Machine.Name
}
fmt.Printf("Service ID: %s\n", svc.ID)
fmt.Printf("Name: %s\n", svc.Name)
fmt.Printf("Mode: %s\n", svc.Mode)
@@ -97,7 +89,7 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
for _, ctr := range allContainers {
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
machine := machinesNamesByID[ctr.MachineID]
machine := ctr.MachineName
if machine == "" {
machine = ctr.MachineID
}
@@ -135,6 +127,6 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
}
}
fmt.Println(t)
lipgloss.Println(t)
return nil
}
@@ -59,7 +59,7 @@ If no services are specified, streams logs from all services defined in the Comp
uc logs -m machine1,machine2 web api`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return runLogs(cmd.Context(), uncli, args, options)
return RunLogs(cmd.Context(), uncli, args, options)
},
GroupID: groupID,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
@@ -74,12 +74,10 @@ If no services are specified, streams logs from all services defined in the Comp
cmd.Flags().AddFlagSet(logs.Flags(&options))
completion.MachinesFlag(cmd)
completion.MachinesFlag(cmd)
return cmd
}
func runLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
serviceArgs, err := logs.ParseServiceArgs(args)
if err != nil {
return err
@@ -6,6 +6,7 @@ import (
"slices"
"strings"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
@@ -82,6 +83,6 @@ func list(ctx context.Context, uncli *cli.CLI) error {
t.Row(row...)
}
fmt.Println(t)
lipgloss.Println(t)
return nil
}
@@ -93,15 +93,17 @@ func NewRunCommand(groupID string) *cobra.Command {
"Give extended privileges to service containers. This is a security risk and should be used with caution.")
cmd.Flags().StringSliceVarP(&opts.publish, "publish", "p", nil,
"Publish a service port to make it accessible outside the cluster. Can be specified multiple times.\n"+
"Format: [hostname:]container_port[/protocol] or [host_ip:]host_port:container_port[/protocol]@host\n"+
"Format: [hostname:]container_port[/protocol] or [host_ip|host_prefix:]host_port:container_port[/protocol]@host\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"+
"Examples:\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 reverse proxy with custom 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 reverse proxy with custom hostname\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\n"+
" -p 192.168.76.0/24:53:5353/udp@host Bind UDP port 5353 to host port 53 on every host IP address\n"+
" contained in the prefix 192.168.76.0/24")
cmd.Flags().StringVar(&opts.pull, "pull", api.PullPolicyMissing,
fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').",
api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever))
@@ -30,9 +30,11 @@ func NewScaleCommand(groupID string) *cobra.Command {
Short: "Scale a replicated service by changing the number of replicas.",
Long: "Scale a replicated service by changing the number of replicas.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
PreRunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0]
@@ -136,7 +138,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
// Ask for confirmation unless auto-confirmed with --yes.
if !opts.yes {
if !tui.IsStdinTerminal() {
if !tui.IsTerminalAvailable() {
return errors.New("cannot ask to confirm scaling plan in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"bytes"
_ "embed"
"fmt"
"strings"
"text/template"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/version"
"github.com/spf13/cobra"
)
//go:embed art.txt
var asciiArt string
// NewVersionCommand creates a new command to print the version and build information for the binary.
func NewVersionCommand() *cobra.Command {
var output string
cmd := &cobra.Command{
Use: "version",
Short: "Show version and build information.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
info := version.GetInfo()
w := cmd.OutOrStdout()
switch output {
case "":
fmt.Fprint(w, humanVersion(info))
case "json":
s, err := info.JSONString()
if err != nil {
return err
}
fmt.Fprintln(w, s)
default:
s, err := templateVersion(output, info)
if err != nil {
return err
}
fmt.Fprint(w, s)
}
return nil
},
}
cmd.Flags().StringVarP(&output, "output", "o", "",
"Output format: 'json' or a Go template (e.g. '{{.Version}}').\n"+
"Run with '-o json' to discover the field names available to the template.\n"+
"(default is human-readable)")
return cmd
}
func humanVersion(info version.Info) string {
var b strings.Builder
b.WriteString(asciiArt)
b.WriteString("\n")
b.WriteString(fmt.Sprintf("uc: Uncloud CLI tool for deploying apps and managing resources (%s)",
tui.URLStyle.Render(version.WebsiteURL)))
b.WriteString("\n\n")
b.WriteString(info.String())
return b.String()
}
// templateVersion renders the version info using the provided Go template.
func templateVersion(tmpl string, info version.Info) (string, error) {
t, err := template.New("version").Parse(tmpl)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer
if err = t.Execute(&buf, info); err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
return buf.String(), nil
}
@@ -6,6 +6,7 @@ import (
"slices"
"strings"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
@@ -95,6 +96,6 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
t.Row(v.Volume.Name, v.Volume.Driver, v.MachineName)
}
fmt.Println(t)
lipgloss.Println(t)
return nil
}
@@ -92,8 +92,7 @@ func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOpti
return fmt.Errorf("confirm removal: %w", err)
}
if !confirmed {
fmt.Println("Cancelled. No volumes were removed.")
return nil
return cli.Cancelled("Cancelled. No volumes were removed.")
}
}
+3 -5
View File
@@ -6,6 +6,7 @@ import (
"strings"
"time"
"charm.land/lipgloss/v2"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
@@ -60,10 +61,7 @@ func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
if opts.machine != "" {
// Proxy requests to the specified machine.
ctx, _, err = client.ProxyMachinesContext(ctx, []string{opts.machine})
if err != nil {
return err
}
ctx = client.ProxySingleMachineContext(ctx, opts.machine)
}
resp, err := client.MachineClient.InspectWireGuardNetwork(ctx, nil)
@@ -136,6 +134,6 @@ func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
)
}
fmt.Println(t)
lipgloss.Println(t)
return nil
}
+4 -3
View File
@@ -10,9 +10,10 @@ import (
func NewRemoveCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "rm [NAME]",
Short: "Remove a cluster.",
Args: cobra.MaximumNArgs(1),
Use: "rm [NAME]",
Short: "Remove a cluster.",
Aliases: []string{"remove", "delete"},
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p := cmd.Context().Value("provisioner").(*ucind.Provisioner)
-441
View File
@@ -1,441 +0,0 @@
package main
import (
"context"
"encoding/json"
"net/netip"
"testing"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/docker"
"github.com/psviderski/uncloud/pkg/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"
)
// mockDockerClient implements pb.DockerClient
type mockDockerClient struct {
pb.DockerClient // Embed to avoid implementing all methods
listResp *pb.ListServiceContainersResponse
listErr error
}
func (m *mockDockerClient) ListServiceContainers(ctx context.Context, in *pb.ListServiceContainersRequest, opts ...grpc.CallOption) (*pb.ListServiceContainersResponse, error) {
return m.listResp, m.listErr
}
// mockClusterClient implements pb.ClusterClient
type mockClusterClient struct {
pb.ClusterClient // Embed to avoid implementing all methods
machinesResp *pb.ListMachinesResponse
machinesErr error
}
func (m *mockClusterClient) ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*pb.ListMachinesResponse, error) {
return m.machinesResp, m.machinesErr
}
func TestCollectContainers_NilMetadata(t *testing.T) {
// Setup container data
containerData := map[string]any{
"Id": "container1",
"Name": "test-container",
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON, err := json.Marshal(containerData)
require.NoError(t, err)
serviceSpecJSON, err := json.Marshal(map[string]any{})
require.NoError(t, err)
// Setup mocks
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: nil, // Simulating the issue: nil metadata
Containers: []*pb.ServiceContainer{
{
Container: containerJSON,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
machineIP := "10.0.0.1"
mockCluster := &mockClusterClient{
machinesResp: &pb.ListMachinesResponse{
Machines: []*pb.MachineMember{
{
Machine: &pb.MachineInfo{
Name: "machine-1",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr(machineIP)),
},
},
State: pb.MachineMember_UP,
},
},
},
}
// Construct client with mocks
cli := &client.Client{
Docker: &docker.Client{
GRPCClient: mockDocker,
},
ClusterClient: mockCluster,
}
// Execute
containers, err := collectContainers(context.Background(), cli)
require.NoError(t, err)
// Verify
assert.Len(t, containers, 1)
if len(containers) > 0 {
c := containers[0]
assert.Equal(t, "container1", c.id)
assert.Equal(t, "machine-1", c.machineName, "Should fall back to the single machine name when metadata is nil")
}
}
func TestCollectContainers_NilMetadata_MultipleMachines_Error(t *testing.T) {
// If we have multiple machines but receive nil metadata, it should return an error as it is ambiguous
// Setup container data
containerData1 := map[string]any{
"Id": "container1",
}
containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]any{
"Id": "container2",
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]any{})
// Setup mocks
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: nil, // Nil metadata
Containers: []*pb.ServiceContainer{
{
Container: containerJSON1,
ServiceSpec: serviceSpecJSON,
},
},
},
{
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
Containers: []*pb.ServiceContainer{
{
Container: containerJSON2,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
mockCluster := &mockClusterClient{
machinesResp: &pb.ListMachinesResponse{
Machines: []*pb.MachineMember{
{
Machine: &pb.MachineInfo{
Name: "machine-1",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
},
},
},
{
Machine: &pb.MachineInfo{
Name: "machine-2",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
},
},
},
},
},
}
// Construct client with mocks
cli := &client.Client{
Docker: &docker.Client{
GRPCClient: mockDocker,
},
ClusterClient: mockCluster,
}
// Execute
_, err := collectContainers(context.Background(), cli)
require.Error(t, err)
assert.Contains(t, err.Error(), "metadata is missing for a machine response")
}
func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
// Verify correct mapping of containers to machines when metadata is present
// Setup container data
containerData1 := map[string]any{
"Id": "container1",
"Name": "container-1",
"Config": map[string]any{
"Image": "image-1",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]any{
"Id": "container2",
"Name": "container-2",
"Config": map[string]any{
"Image": "image-2",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]any{})
// Setup mocks
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: &pb.Metadata{Machine: "10.0.0.1"},
Containers: []*pb.ServiceContainer{
{
Container: containerJSON1,
ServiceSpec: serviceSpecJSON,
},
},
},
{
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
Containers: []*pb.ServiceContainer{
{
Container: containerJSON2,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
mockCluster := &mockClusterClient{
machinesResp: &pb.ListMachinesResponse{
Machines: []*pb.MachineMember{
{
Machine: &pb.MachineInfo{
Name: "machine-1",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
},
},
},
{
Machine: &pb.MachineInfo{
Name: "machine-2",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
},
},
},
},
},
}
cli := &client.Client{
Docker: &docker.Client{GRPCClient: mockDocker},
ClusterClient: mockCluster,
}
containers, err := collectContainers(context.Background(), cli)
require.NoError(t, err)
assert.Len(t, containers, 2)
// Order is not guaranteed by the map iteration in logic or parallel fetch (though here it's mocked sequential),
// but collectContainers just appends.
// We'll find them by ID.
for _, c := range containers {
if c.id == "container1" {
assert.Equal(t, "machine-1", c.machineName)
} else if c.id == "container2" {
assert.Equal(t, "machine-2", c.machineName)
} else {
t.Errorf("unexpected container id: %s", c.id)
}
}
}
func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
// Case: 1 msc with nil metadata but no machines at all
containerData := map[string]any{
"Id": "container1",
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: nil,
Containers: []*pb.ServiceContainer{
{
Container: containerJSON,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
// No machines in cluster response
mockCluster := &mockClusterClient{
machinesResp: &pb.ListMachinesResponse{
Machines: []*pb.MachineMember{},
},
}
cli := &client.Client{
Docker: &docker.Client{GRPCClient: mockDocker},
ClusterClient: mockCluster,
}
containers, err := collectContainers(context.Background(), cli)
require.NoError(t, err)
assert.Len(t, containers, 1)
if len(containers) > 0 {
assert.Equal(t, "unknown", containers[0].machineName)
}
}
func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) {
// Case: msc with metadata that is not in the IP-to-name mapping
containerData := map[string]any{
"Id": "container1",
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: &pb.Metadata{Machine: "10.0.0.99"}, // Unknown IP
Containers: []*pb.ServiceContainer{
{
Container: containerJSON,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
mockCluster := &mockClusterClient{
machinesResp: &pb.ListMachinesResponse{
Machines: []*pb.MachineMember{
{
Machine: &pb.MachineInfo{
Name: "machine-1",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
},
},
},
},
},
}
cli := &client.Client{
Docker: &docker.Client{GRPCClient: mockDocker},
ClusterClient: mockCluster,
}
containers, err := collectContainers(context.Background(), cli)
require.NoError(t, err)
assert.Len(t, containers, 1)
if len(containers) > 0 {
// Should fallback to the IP/string in metadata
assert.Equal(t, "10.0.0.99", containers[0].machineName)
}
}
+5
View File
@@ -0,0 +1,5 @@
_ _ _
_ _ _ __ ___| | ___ _ _ __| | __| |
| | | | '_ \ / __| |/ _ \| | | |/ _` |/ _` |
| |_| | | | | (__| | (_) | |_| | (_| | (_| |
\__,_|_| |_|\___|_|\___/ \__,_|\__,_|\__,_|
+7 -3
View File
@@ -7,9 +7,11 @@ import (
"os/signal"
"syscall"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/daemon"
"github.com/psviderski/uncloud/internal/log"
"github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/metrics"
"github.com/psviderski/uncloud/internal/version"
"github.com/spf13/cobra"
)
@@ -24,7 +26,7 @@ func main() {
cmd := &cobra.Command{
Use: "uncloudd",
Short: "Uncloud machine daemon.",
Version: version.String(),
Long: "Uncloud machine daemon.\n" + tui.URLStyle.Render(version.WebsiteURL),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -39,11 +41,13 @@ func main() {
},
}
cmd.PersistentFlags().StringVarP(&dataDir, "data-dir", "d", machine.DefaultDataDir,
"Directory for storing persistent machine state")
"Directory for storing persistent machine state.")
_ = cmd.MarkFlagDirname("data-dir")
// Add dial-stdio subcommand.
cmd.AddCommand(newDialStdioCommand())
cmd.AddCommand(newVersionCommand())
metrics.Version.WithLabelValues(version.String()).Set(1)
// ctx is canceled when the daemon command is interrupted.
ctx, cancel := context.WithCancel(context.Background())
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"bytes"
_ "embed"
"fmt"
"strings"
"text/template"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/version"
"github.com/spf13/cobra"
)
//go:embed art.txt
var asciiArt string
// newVersionCommand creates a new command to print the version and build information for the binary.
func newVersionCommand() *cobra.Command {
var output string
cmd := &cobra.Command{
Use: "version",
Short: "Show version and build information.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
info := version.GetInfo()
w := cmd.OutOrStdout()
switch output {
case "":
fmt.Fprint(w, humanVersion(info))
case "json":
s, err := info.JSONString()
if err != nil {
return err
}
fmt.Fprintln(w, s)
default:
s, err := templateVersion(output, info)
if err != nil {
return err
}
fmt.Fprint(w, s)
}
return nil
},
}
cmd.Flags().StringVarP(&output, "output", "o", "",
"Output format: 'json' or a Go template (e.g., '{{.Version}}').\n"+
"Run with '-o json' to discover the field names available to the template.\n"+
"(default is human-readable)")
return cmd
}
func humanVersion(info version.Info) string {
var b strings.Builder
b.WriteString(asciiArt)
b.WriteString("\n")
b.WriteString(fmt.Sprintf("uncloudd: Uncloud machine daemon (%s)",
tui.URLStyle.Render(version.WebsiteURL)))
b.WriteString("\n\n")
b.WriteString(info.String())
return b.String()
}
// templateVersion renders the version info using the provided Go template.
func templateVersion(tmpl string, info version.Info) (string, error) {
t, err := template.New("version").Parse(tmpl)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer
if err = t.Execute(&buf, info); err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
return buf.String(), nil
}
+2 -2
View File
@@ -124,8 +124,8 @@ require (
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
+4 -4
View File
@@ -660,8 +660,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -694,8 +694,8 @@ golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+1 -1
View File
@@ -81,7 +81,7 @@ func TestDiscovery() error {
}
endpoints[i] = &pb.Endpoint{
Ip: ip,
Port: network.WireGuardPort,
Port: network.DefaultWireGuardPort,
}
}
if err = client.SetLocalData(
+13 -10
View File
@@ -11,10 +11,11 @@ require (
github.com/Masterminds/semver v1.5.0
github.com/Masterminds/squirrel v1.5.4
github.com/alecthomas/chroma/v2 v2.20.0
github.com/caarlos0/go-version v0.2.2
github.com/caddyserver/caddy/v2 v2.8.4
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/colorprofile v0.4.2
github.com/charmbracelet/x/ansi v0.11.6
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/compose-spec/compose-go/v2 v2.9.0
github.com/containerd/errdefs v1.0.0
github.com/containerd/platforms v1.0.0-rc.1
@@ -29,12 +30,15 @@ require (
github.com/goccy/go-yaml v1.17.1
github.com/google/go-cmp v0.7.0
github.com/google/go-containerregistry v0.20.2
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-shellwords v1.0.12
github.com/miekg/dns v1.1.65
github.com/mitchellh/mapstructure v1.5.0
github.com/moby/term v0.5.2
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/prometheus/client_golang v1.22.0
github.com/psviderski/unregistry v0.4.1
github.com/siderolabs/grpc-proxy v0.5.1
github.com/spf13/cobra v1.10.1
@@ -44,8 +48,8 @@ require (
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.42.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.45.0
golang.org/x/term v0.34.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
@@ -94,7 +98,7 @@ require (
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -164,7 +168,6 @@ require (
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/handlers v1.5.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
@@ -199,13 +202,12 @@ require (
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/libdns/libdns v0.2.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
@@ -246,7 +248,6 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
@@ -357,3 +358,5 @@ require (
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
replace charm.land/bubbletea/v2 => github.com/unlabs-dev/bubbletea/v2 v2.0.8-uncloud.1
+18 -16
View File
@@ -1,7 +1,5 @@
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/huh/v2 v2.0.1 h1:9vhBjlIDuikdPKH+qnoG++GERVxqY0Lkv14xW57lj98=
charm.land/huh/v2 v2.0.1/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
@@ -138,6 +136,8 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembj
github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50=
github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o=
github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
github.com/caarlos0/go-version v0.2.2 h1:5r+nlrg4H2wOVwWjqRqRRIRbZ7ytRmjC9xoMIP0a5kQ=
github.com/caarlos0/go-version v0.2.2/go.mod h1:X+rI5VAtJDpcjCjeEIXpxGa5+rTcgur1FK66wS0/944=
github.com/caddyserver/caddy/v2 v2.8.4 h1:q3pe0wpBj1OcHFZ3n/1nl4V4bxBrYoSoab7rL9BMYNk=
github.com/caddyserver/caddy/v2 v2.8.4/go.mod h1:vmDAHp3d05JIvuhc24LmnxVlsZmWnUwbP5WMjzcMPWw=
github.com/caddyserver/certmagic v0.21.4 h1:e7VobB8rffHv8ZZpSiZtEwnLDHUwLVYLWzWSa1FfKI0=
@@ -155,12 +155,12 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
@@ -599,8 +599,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
@@ -621,8 +621,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@@ -914,6 +914,8 @@ github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea/go.mod h1:WPnis/6
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab h1:H6aJ0yKQ0gF49Qb2z5hI1UHxSQt4JMyxebFR15KnApw=
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab/go.mod h1:ulncasL3N9uLrVann0m+CDlJKWsIAP34MPcOJF6VRvc=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/unlabs-dev/bubbletea/v2 v2.0.8-uncloud.1 h1:898LVw5QvmRX/y+pgzCo2pE/NbN9CZfdpiVnzAeAFDY=
github.com/unlabs-dev/bubbletea/v2 v2.0.8-uncloud.1/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ=
github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo=
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
@@ -1108,8 +1110,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1150,8 +1152,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+46 -12
View File
@@ -11,6 +11,8 @@ import (
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/cluster"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/sshexec"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
@@ -174,6 +176,8 @@ type InitClusterOptions struct {
Version string
AutoConfirm bool
WireguardEndpoints []*pb.IPPort
WireguardMTU int
WireguardPort int
}
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
@@ -234,6 +238,8 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
MachineName: opts.MachineName,
Network: pb.NewIPPrefix(opts.Network),
WireguardEndpoints: opts.WireguardEndpoints,
WireguardMtu: int32(opts.WireguardMTU),
WireguardPort: int32(opts.WireguardPort),
}
if opts.PublicIP != nil {
if opts.PublicIP.IsValid() {
@@ -317,13 +323,15 @@ type AddMachineOptions struct {
Version string
AutoConfirm bool
WireguardEndpoints []*pb.IPPort
WireguardMTU int
WireguardPort int
}
// AddMachine provisions a remote machine and adds it to the cluster. It returns a cluster client and a machine client.
// The cluster client is connected to the existing machine in the cluster. It was used to add the new machine to the
// 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) {
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (_ *client.Client, _ *client.Client, err error) {
contextName := cli.ContextOverrideOrCurrent()
c, err := cli.ConnectCluster(ctx)
if err != nil {
@@ -344,16 +352,19 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
machineClient.Close()
}
}()
// IMPORTANT: 'err' is a named return value so the deferred cleanups above observe it on every error return.
// Do not shadow it with ':=' in a nested scope, or the deferred client cleanup would be skipped on error.
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
// TODO: refactor to use client.InspectMachine.
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
inspectResp, err := machineClient.MachineClient.InspectMachine(ctx, nil)
if err != nil {
return nil, nil, fmt.Errorf("inspect machine: %w", err)
}
minfo := inspectResp.Machines[0].Machine
if minfo.Id != "" {
// Check if the machine is already a member of this cluster.
machines, err := c.ListMachines(ctx, nil)
var machines api.MachineMembersList
machines, err = c.ListMachines(ctx, nil)
if err != nil {
return nil, nil, fmt.Errorf("list cluster machines: %w", err)
}
@@ -401,11 +412,32 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
} else {
endpoints = make([]*pb.IPPort, len(token.Endpoints))
for i, addrPort := range token.Endpoints {
// If a custom WireGuard port is specified, override the port from the token endpoints
// since the token was generated before the machine knows its configured port.
if opts.WireguardPort != 0 && opts.WireguardPort != network.DefaultWireGuardPort {
addrPort = netip.AddrPortFrom(addrPort.Addr(), uint16(opts.WireguardPort))
}
endpoints[i] = pb.NewIPPort(addrPort)
}
}
// Default the machine name to the machine's hostname when not explicitly provided, ensuring it is
// unique within the cluster.
machineName := opts.MachineName
if machineName == "" {
var machines api.MachineMembersList
if machines, err = c.ListMachines(ctx, nil); err != nil {
return nil, nil, fmt.Errorf("list cluster machines: %w", err)
}
existing := make([]string, len(machines))
for i, m := range machines {
existing[i] = m.Machine.Name
}
if machineName, err = cluster.DefaultMachineName(minfo.Hostname, existing); err != nil {
return nil, nil, fmt.Errorf("generate machine name: %w", err)
}
}
addReq := &pb.AddMachineRequest{
Name: opts.MachineName,
Name: machineName,
Network: &pb.NetworkConfig{
Endpoints: endpoints,
PublicKey: token.PublicKey,
@@ -425,16 +457,16 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
return nil, nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err)
}
// Get the current store DB version from the cluster to pass to the join request.
var storeDBVersion int64
inspectResp, err := c.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
// Snapshot the cluster store version so the new machine can catch up before participating.
var storeVersion map[string]int64
inspectResp, err = c.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
if err != nil {
// TODO(lhf): remove Unimplemented check when v0.17.0 is released.
if status.Convert(err).Code() != codes.Unimplemented {
return nil, nil, fmt.Errorf("inspect current cluster machine: %w", err)
}
} else {
storeDBVersion = inspectResp.Machines[0].StoreDbVersion
storeVersion = inspectResp.Machines[0].StoreVersion
}
// Get the most up-to-date list of other machines in the cluster to include them in the join request.
@@ -451,9 +483,11 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
// Configure the remote machine to join the cluster.
joinReq := &pb.JoinClusterRequest{
Machine: addResp.Machine,
OtherMachines: otherMachines,
MinStoreDbVersion: storeDBVersion,
Machine: addResp.Machine,
OtherMachines: otherMachines,
MinStoreVersion: storeVersion,
WireguardMtu: int32(opts.WireguardMTU),
WireguardPort: int32(opts.WireguardPort),
}
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
return nil, nil, fmt.Errorf("join cluster: %w", err)
+5 -1
View File
@@ -11,6 +11,11 @@ import (
)
func Contexts(ctx context.Context, uncli *cli.CLI, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
// There are no contexts to complete when the CLI uses a direct machine connection (--connect) without a config.
if uncli.Config == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
contexts := slices.Sorted(maps.Keys(uncli.Config.Contexts))
names := []cobra.Completion{}
@@ -21,7 +26,6 @@ func Contexts(ctx context.Context, uncli *cli.CLI, args []string, toComplete str
if strings.HasPrefix(context, toComplete) {
names = append(names, context)
}
names = append(names, context)
}
return names, cobra.ShellCompDirectiveNoFileComp
+2 -1
View File
@@ -10,7 +10,8 @@ import (
)
func Machines(ctx context.Context, uncli *cli.CLI, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
client, err := uncli.ConnectCluster(ctx)
// Disable the connection progress output to not interfere with the shell completion output.
client, err := uncli.ConnectClusterWithOptions(ctx, cli.ConnectOptions{})
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
+2 -1
View File
@@ -11,7 +11,8 @@ import (
)
func Services(ctx context.Context, uncli *cli.CLI, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
client, err := uncli.ConnectCluster(ctx)
// Disable the connection progress output to not interfere with the shell completion output.
client, err := uncli.ConnectClusterWithOptions(ctx, cli.ConnectOptions{})
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
+2 -1
View File
@@ -11,7 +11,8 @@ import (
)
func Volumes(ctx context.Context, uncli *cli.CLI, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
client, err := uncli.ConnectCluster(ctx)
// Disable the connection progress output to not interfere with the shell completion output.
client, err := uncli.ConnectClusterWithOptions(ctx, cli.ConnectOptions{})
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
+4 -5
View File
@@ -31,10 +31,9 @@ func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts Con
}
// connectClusterWithProgress connects to the cluster while displaying a progress spinner.
// If the stdout is not a terminal, it falls back to simple progress logs to stderr.
// If a terminal is not available, it falls back to simple progress logs to stderr.
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
// If stdout is not a terminal, fall back to simple progress logs.
if !tui.IsStdoutTerminal() {
if !tui.IsTerminalAvailable() {
fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
cli, err := connectCluster(ctx, conn)
if err != nil {
@@ -45,8 +44,8 @@ func connectClusterWithProgress(ctx context.Context, conn config.MachineConnecti
return cli, err
}
// Run the connection TUI model.
p := tea.NewProgram(newConnectModel(ctx, conn))
// Run the connection TUI model. Render to stderr so stdout stays clean for command output.
p := tea.NewProgram(newConnectModel(ctx, conn), tea.WithOutput(os.Stderr))
model, err := p.Run()
if err != nil {
return nil, fmt.Errorf("run connection TUI: %w", err)
+4 -5
View File
@@ -8,7 +8,6 @@ import (
"strings"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/spf13/cobra"
)
@@ -40,18 +39,18 @@ func BindEnvToFlag(cmd *cobra.Command, flagName, envVar string) {
}
// ParseWireGuardEndpoints parses a list of endpoint strings into a list of IPPort protobuf messages. Each value can
// be an IP address, IP:PORT, IPv6, or [IPv6]:PORT. If the port is omitted, the default WireGuard port is used.
func ParseWireGuardEndpoints(values []string) ([]*pb.IPPort, error) {
// be an IP address, IP:PORT, IPv6, or [IPv6]:PORT. If the port is omitted, defaultPort is used.
func ParseWireGuardEndpoints(values []string, defaultPort uint16) ([]*pb.IPPort, error) {
endpoints := make([]*pb.IPPort, 0, len(values))
for _, v := range values {
ap, err := netip.ParseAddrPort(v)
if err != nil {
// Try parsing as a bare IP address and use the default WireGuard port.
// Try parsing as a bare IP address and use the provided default port.
addr, addrErr := netip.ParseAddr(v)
if addrErr != nil {
return nil, fmt.Errorf("invalid endpoint '%s': must be IP, IPv6, IP:PORT, or [IPv6]:PORT", v)
}
ap = netip.AddrPortFrom(addr, network.WireGuardPort)
ap = netip.AddrPortFrom(addr, defaultPort)
}
endpoints = append(endpoints, pb.NewIPPort(ap))
}
+2 -2
View File
@@ -129,7 +129,7 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
output.WriteString(f.formatMachine(entry.Metadata.MachineName))
output.WriteString(" ")
// Service/container_id or service name for a systemd service.
// Service/container_id or service name for a system service.
output.WriteString(f.formatService(entry.Metadata.ServiceName, entry.Metadata.ContainerID, entry.Metadata.Hook))
output.WriteString(" ")
@@ -160,7 +160,7 @@ func (f *Formatter) printError(entry api.ServiceLogEntry) {
stringid.TruncateID(entry.Metadata.ContainerID),
entry.Metadata.MachineName)
} else {
msg = fmt.Sprintf("WARNING: log stream from systemd service '%s' on machine '%s'",
msg = fmt.Sprintf("WARNING: log stream from system service '%s' on machine '%s'",
entry.Metadata.ServiceName,
entry.Metadata.MachineName)
}
+7 -5
View File
@@ -99,15 +99,15 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string
}
func promptResetMachine() error {
if !tui.IsStdinTerminal() {
if !tui.IsTerminalAvailable() {
return errors.New("the remote machine is already initialised as a cluster member; " +
"cannot ask to confirm reset in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
}
fmt.Println(tui.Red.Render("The remote machine is already initialised as a cluster member. Resetting it will:\n" +
"- Remove all service containers from the machine\n" +
"- Reset the machine to the uninitialised state"))
fmt.Fprintln(os.Stderr, tui.Red.Render("The remote machine is already initialised as a cluster member. Resetting it will:\n"+
"- Remove all service containers from the machine\n"+
"- Reset the Uncloud daemon on the machine to the uninitialised state"))
var confirm bool
form := huh.NewForm(
@@ -119,7 +119,9 @@ func promptResetMachine() error {
Value(&confirm),
),
).WithTheme(tui.ThemeConfirmDanger()).
WithAccessible(true)
WithAccessible(true).
// Render to stderr so stdout stays clean for command output.
WithOutput(os.Stderr)
if err := form.Run(); err != nil {
return fmt.Errorf("prompt user to confirm: %w", err)
}
+16 -1
View File
@@ -25,7 +25,9 @@ func Confirm(title string) (bool, error) {
Value(&confirmed),
),
).WithTheme(ThemeConfirm()).
WithAccessible(true)
WithAccessible(true).
// Render to stderr so stdout stays clean for command output.
WithOutput(os.Stderr)
if err := form.Run(); err != nil {
return false, err
}
@@ -51,6 +53,14 @@ func ThemeConfirmDanger() huh.Theme {
})
}
// IsTerminalAvailable reports whether the control terminal (TTY) is available so an interactive TUI can run.
func IsTerminalAvailable() bool {
// Bubbletea interactive programs read keyboard input from stdin and render to stdout (default) or stderr (uncloud),
// so both must be terminals. In particular, bubbletea falls back to opening /dev/tty when stdin is not a terminal,
// which fails when there is no controlling terminal. See https://github.com/psviderski/uncloud/issues/386
return IsStdinTerminal() && IsStderrTerminal()
}
// IsStdinTerminal checks if the standard input is a terminal (TTY).
func IsStdinTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
@@ -61,6 +71,11 @@ func IsStdoutTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
// IsStderrTerminal checks if the standard error is a terminal (TTY).
func IsStderrTerminal() bool {
return term.IsTerminal(int(os.Stderr.Fd()))
}
// TerminalWidth returns the width of the terminal.
// Returns 0 if stdout is not a terminal or the width cannot be determined.
func TerminalWidth() int {
+37
View File
@@ -0,0 +1,37 @@
package tui
import (
"context"
"fmt"
"os"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
)
// RunSpinner shows an animated spinner that prints title while running action.
// When the terminal (TTY) is not available, it prints the title as plain text.
// It renders to stderr so stdout stays clean for command output.
func RunSpinner(ctx context.Context, title string, action func(ctx context.Context) error) error {
// Fall back to plain text when a terminal is not available to avoid the bubbletea /dev/tty error and to keep
// escape codes out of redirected output.
if !IsTerminalAvailable() {
fmt.Fprintln(os.Stderr, title)
return action(ctx)
}
return spinner.New().
// Leading space offsets the title from the spinner glyph.
Title(" " + title).
Type(spinner.MiniDot).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
WithOutput(os.Stderr).
Context(ctx).
ActionWithErr(action).
Run()
}
+1
View File
@@ -19,6 +19,7 @@ var (
BoldYellow = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Yellow)
NameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("152"))
URLStyle = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.BrightBlue)
)
// FormatImage renders an image reference with the given style, using a faint colon separator for tagged images.
+52 -30
View File
@@ -19,10 +19,10 @@ import (
const (
// http2ConnectTimeout is the maximum amount of time an HTTP2 client will wait for a connection to be established.
http2ConnectTimeout = 3 * time.Second
// http2MaxRetryTime is the maximum amount of time an HTTP2 client will retry a request.
http2MaxRetryTime = 10 * time.Second
// resubscribeMaxRetryTime is the maximum amount of time an API client will retry resubscribing to a query after
// an error occurs.
// http2MaxRetryTime bounds the transport retry of a single request. Kept short so it only absorbs transient
// blips. Retrying during a Corrosion outage is the job of the higher-level recovery loops.
http2MaxRetryTime = 2 * time.Second
// resubscribeMaxRetryTime bounds resubscribing to a query after an error.
resubscribeMaxRetryTime = 60 * time.Second
)
@@ -33,39 +33,43 @@ type APIClient struct {
newResubBackoff func() backoff.BackOff
}
// NewAPIClient creates a new Corrosion API client. The client retries on network errors using an exponential backoff
// policy with a maximum interval of 1 second and a maximum elapsed time of 10 seconds.
// It automatically resubscribes to active subscriptions if an error occurs using an exponential backoff policy with a
// maximum interval of 1 second and a maximum elapsed time of 60 seconds.
// NewAPIClient creates a new Corrosion API client. The bearerToken is sent in the Authorization header of every
// request to authenticate against Corrosion API.
//
// Retries are split by failure mode: the transport briefly retries a single request on transient network errors, while
// subscriptions resubscribe from the last change ID when their stream breaks and own the wait while Corrosion is down.
//
// Use the WithHTTP2Client option to provide a custom HTTP client and the WithResubscribeBackoff option to change the
// backoff policy for resubscribing to a query.
func NewAPIClient(addr netip.AddrPort, opts ...APIClientOption) (*APIClient, error) {
func NewAPIClient(addr netip.AddrPort, bearerToken string, opts ...APIClientOption) (*APIClient, error) {
baseURL, err := url.Parse(fmt.Sprintf("http://%s", addr))
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
c := &APIClient{
baseURL: baseURL,
client: &http.Client{
Transport: &RetryRoundTripper{
Base: &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
dialer := &net.Dialer{
Timeout: http2ConnectTimeout,
}
return dialer.DialContext(ctx, network, addr)
},
},
NewBackoff: func() backoff.BackOff {
return backoff.NewExponentialBackOff(
backoff.WithInitialInterval(100*time.Millisecond),
backoff.WithMaxInterval(1*time.Second),
backoff.WithMaxElapsedTime(http2MaxRetryTime),
)
transport := &AuthRoundTripper{
Base: &RetryRoundTripper{
Base: &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
dialer := &net.Dialer{
Timeout: http2ConnectTimeout,
}
return dialer.DialContext(ctx, network, addr)
},
},
NewBackoff: func() backoff.BackOff {
return backoff.NewExponentialBackOff(
backoff.WithInitialInterval(100*time.Millisecond),
backoff.WithMaxElapsedTime(http2MaxRetryTime),
)
},
},
Token: bearerToken,
}
c := &APIClient{
baseURL: baseURL,
client: &http.Client{Transport: transport},
newResubBackoff: func() backoff.BackOff {
return backoff.NewExponentialBackOff(
backoff.WithInitialInterval(100*time.Millisecond),
@@ -83,6 +87,8 @@ func NewAPIClient(addr netip.AddrPort, opts ...APIClientOption) (*APIClient, err
type APIClientOption func(*APIClient)
// WithHTTP2Client replaces the client's HTTP transport. The provided client bypasses the built-in bearer-token
// injection, so the caller is responsible for setting the Authorization header.
func WithHTTP2Client(client *http.Client) APIClientOption {
return func(c *APIClient) {
c.client = client
@@ -97,6 +103,23 @@ func WithResubscribeBackoff(newBackoff func() backoff.BackOff) APIClientOption {
}
}
// AuthRoundTripper sets the Authorization header on every outgoing request. An empty Token leaves the header untouched.
type AuthRoundTripper struct {
Base http.RoundTripper
Token string
}
func (rt *AuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if rt.Token == "" {
return rt.Base.RoundTrip(req)
}
// RoundTripper contract: must not mutate the caller's request.
req = req.Clone(req.Context())
req.Header.Set("Authorization", "Bearer "+rt.Token)
return rt.Base.RoundTrip(req)
}
// RetryRoundTripper retries a single HTTP request on transient network errors using the backoff returned by NewBackoff.
type RetryRoundTripper struct {
Base http.RoundTripper
// NewBackoff creates a new backoff policy for each request.
@@ -107,8 +130,7 @@ func (rt *RetryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error
roundTrip := func() (*http.Response, error) {
resp, err := rt.Base.RoundTrip(req)
if err != nil {
var opErr *net.OpError
if errors.As(err, &opErr) {
if _, ok := errors.AsType[*net.OpError](err); ok {
// Not certain, but I expect operational errors should generally be retryable.
slog.Debug("Retrying corrosion API request due to network error.", "error", err)
return nil, err
+60
View File
@@ -0,0 +1,60 @@
package corrosion
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuthRoundTripper_SetsAuthorizationHeader(t *testing.T) {
t.Parallel()
const token = "test-token-1234567890"
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
rt := &AuthRoundTripper{Base: http.DefaultTransport, Token: token}
client := &http.Client{Transport: rt}
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, "Bearer "+token, gotAuth)
// Caller's request must not be mutated by the RoundTripper.
assert.Empty(t, req.Header.Get("Authorization"))
}
func TestAuthRoundTripper_EmptyTokenSkipsHeader(t *testing.T) {
t.Parallel()
var headerSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, headerSeen = r.Header["Authorization"]
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
rt := &AuthRoundTripper{Base: http.DefaultTransport, Token: ""}
client := &http.Client{Transport: rt}
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.False(t, headerSeen, "Authorization header should not be sent when token is empty")
}
+47 -3
View File
@@ -22,6 +22,10 @@ var (
ChangeTypeDelete ChangeType = "delete"
)
// ErrSubscriptionNotFound is returned when resubscribing to a subscription that Corrosion
// no longer knows about (HTTP 404).
var ErrSubscriptionNotFound = errors.New("subscription not found")
type ChangeEvent struct {
Type ChangeType
RowID uint64
@@ -286,10 +290,25 @@ func (c *APIClient) resubscribeWithBackoffFn(id string) func(context.Context, ui
return nil
}
return func(ctx context.Context, fromChange uint64) (*Subscription, error) {
boff := backoff.WithContext(c.newResubBackoff(), ctx)
return backoff.RetryWithData(func() (*Subscription, error) {
slog.Debug("Retrying to resubscribe to Corrosion query.", "id", id, "from_change", fromChange)
return c.ResubscribeContext(ctx, id, fromChange)
}, c.newResubBackoff())
sub, err := c.ResubscribeContext(ctx, id, fromChange)
if err != nil {
// A gone subscription can never be resubscribed, so stop retrying immediately and let the caller
// recover by creating a fresh subscription.
if errors.Is(err, ErrSubscriptionNotFound) {
slog.Error("Corrosion subscription no longer exists, giving up resubscribing.",
"id", id, "from_change", fromChange)
return nil, backoff.Permanent(fmt.Errorf("resubscribe to %s: %w", id, err))
}
// Don't log retries triggered by context cancellation, the backoff will stop immediately.
if ctx.Err() == nil {
slog.Error("Failed to resubscribe to Corrosion query. Retrying with backoff.",
"id", id, "from_change", fromChange, "err", err)
}
}
return sub, err
}, boff)
}
}
@@ -312,13 +331,38 @@ func (c *APIClient) ResubscribeContext(ctx context.Context, id string, fromChang
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
resp.Body.Close()
return nil, ErrSubscriptionNotFound
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
}
// Since https://github.com/superfly/corrosion/pull/355, Corrosion treats a resubscription from change 0 like
// a fresh subscription: it replays the full query snapshot (a columns event, all rows, and an end-of-query event)
// before streaming changes. We don't expose rows in this case, so drain the snapshot here before consuming changes.
if fromChange == 0 {
rows, err := newRows(ctx, resp.Body, false)
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("parse resubscribe response: %w", err)
}
// Drain the replayed rows until the end-of-query event to reach the change stream.
for rows.Next() {
}
if err = rows.Err(); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("drain resubscribe snapshot: %w", err)
}
return newSubscription(ctx, id, nil, rows.body, rows.decoder, c.resubscribeWithBackoffFn(id)), nil
}
return newSubscription(ctx, id, nil, resp.Body, nil, c.resubscribeWithBackoffFn(id)), nil
}
+216
View File
@@ -0,0 +1,216 @@
package corrosion
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestClient builds an APIClient pointed at srv with a fast resubscribe backoff for tests.
func newTestClient(t *testing.T, srv *httptest.Server) *APIClient {
t.Helper()
baseURL, err := url.Parse(srv.URL)
require.NoError(t, err)
return &APIClient{
baseURL: baseURL,
client: srv.Client(),
newResubBackoff: func() backoff.BackOff {
return backoff.NewExponentialBackOff(
backoff.WithInitialInterval(time.Millisecond),
backoff.WithMaxInterval(10*time.Millisecond),
backoff.WithMaxElapsedTime(5*time.Second),
)
},
}
}
func flushString(t *testing.T, w http.ResponseWriter, s string) {
t.Helper()
_, err := w.Write([]byte(s))
require.NoError(t, err)
w.(http.Flusher).Flush()
}
// TestSubscription_ResubscribeFromZeroDrainsSnapshot verifies that when the connection drops before any
// change is seen (lastChangeID == 0) and Corrosion replays the full query snapshot on resubscription from
// change 0, the change handler drains the snapshot and delivers the subsequent change instead of looping on
// "expected change event, got: {Columns:...}".
// The change of behaviour in Corrosion: https://github.com/superfly/corrosion/pull/355
func TestSubscription_ResubscribeFromZeroDrainsSnapshot(t *testing.T) {
t.Parallel()
const snapshot = `{"columns":["id","info"]}
{"row":[1,["a","b"]]}
{"eoq":{"time":1e-7,"change_id":0}}
`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
// Initial subscription: send the snapshot, then end the stream to force a resubscribe.
w.Header().Set("corro-query-id", "test-sub")
w.WriteHeader(http.StatusOK)
flushString(t, w, snapshot)
case http.MethodGet:
// Resubscription from change 0: Corrosion v1.0.0+ replays the full snapshot, then changes.
require.Equal(t, "0", r.URL.Query().Get("from"))
w.Header().Set("corro-query-id", "test-sub")
w.WriteHeader(http.StatusOK)
flushString(t, w, snapshot+`{"change":["insert",2,["c","d"],1]}`+"\n")
// Keep the connection open so the handler doesn't end the stream and trigger another resubscribe.
<-r.Context().Done()
default:
require.FailNow(t, fmt.Sprintf("unexpected request method: %s", r.Method))
}
}))
defer srv.Close()
client := newTestClient(t, srv)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sub, err := client.SubscribeContext(ctx, "SELECT id, info FROM machines", nil, false)
require.NoError(t, err)
// Consume the initial rows so Changes can be called.
rows := sub.Rows()
for rows.Next() {
}
require.NoError(t, rows.Err())
changes, err := sub.Changes()
require.NoError(t, err)
select {
case change := <-changes:
require.NotNil(t, change, "changes channel closed with error: %v", sub.Err())
assert.Equal(t, ChangeTypeInsert, change.Type)
assert.Equal(t, uint64(2), change.RowID)
assert.Equal(t, uint64(1), change.ChangeID)
case <-time.After(5 * time.Second):
require.FailNow(t, "timed out waiting for a change after resubscribe", sub.Err())
}
}
// TestSubscription_ResubscribeFromNonZeroSkipsSnapshot verifies that a resubscription from a non-zero change
// streams changes directly, without a replayed snapshot, and the change handler delivers them.
func TestSubscription_ResubscribeFromNonZeroSkipsSnapshot(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
// Initial subscription with a non-zero last change id, then end the stream to force a resubscribe.
w.Header().Set("corro-query-id", "test-sub")
w.WriteHeader(http.StatusOK)
flushString(t, w, `{"columns":["id","info"]}`+"\n"+
`{"row":[1,["a","b"]]}`+"\n"+
`{"eoq":{"time":1e-7,"change_id":5}}`+"\n")
case http.MethodGet:
// Resubscription from change 5: only changes are streamed, no snapshot.
require.Equal(t, "5", r.URL.Query().Get("from"))
require.False(t, strings.Contains(r.URL.RawQuery, "skip_rows"))
w.WriteHeader(http.StatusOK)
flushString(t, w, `{"change":["update",1,["a","b2"],6]}`+"\n")
<-r.Context().Done()
default:
require.FailNow(t, fmt.Sprintf("unexpected request method: %s", r.Method))
}
}))
defer srv.Close()
client := newTestClient(t, srv)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sub, err := client.SubscribeContext(ctx, "SELECT id, info FROM machines", nil, false)
require.NoError(t, err)
rows := sub.Rows()
for rows.Next() {
}
require.NoError(t, rows.Err())
changes, err := sub.Changes()
require.NoError(t, err)
select {
case change := <-changes:
require.NotNil(t, change, "changes channel closed with error: %v", sub.Err())
assert.Equal(t, ChangeTypeUpdate, change.Type)
assert.Equal(t, uint64(6), change.ChangeID)
case <-time.After(5 * time.Second):
require.FailNow(t, "timed out waiting for a change after resubscribe", sub.Err())
}
}
// TestSubscription_ResubscribeNotFoundFailsFast verifies that when Corrosion returns 404 to a resubscription
// (the subscription no longer exists, e.g. after a restart that dropped it), the change handler stops retrying
// immediately and closes the changes channel with ErrSubscriptionNotFound, instead of retrying for the full
// backoff window.
func TestSubscription_ResubscribeNotFoundFailsFast(t *testing.T) {
t.Parallel()
var getCount atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
// Initial subscription, then end the stream to force a resubscribe.
w.Header().Set("corro-query-id", "test-sub")
w.WriteHeader(http.StatusOK)
flushString(t, w, `{"columns":["id","info"]}`+"\n"+
`{"row":[1,["a","b"]]}`+"\n"+
`{"eoq":{"time":1e-7,"change_id":5}}`+"\n")
case http.MethodGet:
// Resubscription: Corrosion no longer knows this subscription.
getCount.Add(1)
http.Error(w, "", http.StatusNotFound)
default:
require.FailNow(t, fmt.Sprintf("unexpected request method: %s", r.Method))
}
}))
defer srv.Close()
client := newTestClient(t, srv)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sub, err := client.SubscribeContext(ctx, "SELECT id, info FROM machines", nil, false)
require.NoError(t, err)
rows := sub.Rows()
for rows.Next() {
}
require.NoError(t, rows.Err())
changes, err := sub.Changes()
require.NoError(t, err)
select {
case change := <-changes:
// The channel must close (nil change) rather than deliver anything.
require.Nil(t, change, "expected the changes channel to close on a gone subscription")
case <-time.After(5 * time.Second):
require.FailNow(t, "timed out waiting for the changes channel to close")
}
require.ErrorIs(t, sub.Err(), ErrSubscriptionNotFound)
// The 404 must not be retried: exactly one GET resubscription request was made.
assert.Equal(t, int32(1), getCount.Load())
}
+2 -2
View File
@@ -31,8 +31,8 @@ const (
//
// The two minimums are independent: a client might require a newer daemon for new
// features, while that same daemon could still handle requests from older clients.
MinClientVersion = "0.0.0"
MinServerVersion = "0.0.0"
MinClientVersion = "0.20.0"
MinServerVersion = "0.20.0"
ReleaseURL = "https://github.com/psviderski/uncloud/releases/latest"
)
-20
View File
@@ -10,31 +10,11 @@ import (
"github.com/psviderski/uncloud/pkg/api"
)
const (
UnitUncloud = "uncloud"
UnitDocker = "docker"
UnitCorrosion = "uncloud-corrosion"
)
func ValidUnit(unit string) bool {
switch unit {
case UnitUncloud:
case UnitDocker:
case UnitCorrosion:
default:
return false
}
return true
}
const journalctl = "journalctl"
var commandContext = exec.CommandContext // allow override for test
func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.ReadCloser, func() error, error) {
if !ValidUnit(unit) {
return nil, nil, fmt.Errorf("journal logs: invalid unit: %s", unit)
}
args := []string{"-u", unit, "--no-hostname"}
args = append(args, "-n")
if opts.Tail > -1 {
-5
View File
@@ -3,7 +3,6 @@ package journal
import (
"bytes"
"context"
"fmt"
"slices"
"strconv"
"time"
@@ -13,10 +12,6 @@ import (
// Logs streams logs from a service and returns entries via a channel.
func Logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (<-chan api.LogEntry, error) {
if !ValidUnit(unit) {
return nil, fmt.Errorf("journal logs: invalid unit: %s", unit)
}
reader, wait, err := logs(ctx, unit, opts)
if err != nil {
return nil, err
+122 -296
View File
@@ -124,7 +124,7 @@ func (x DNSRecord_RecordType) Number() protoreflect.EnumNumber {
// Deprecated: Use DNSRecord_RecordType.Descriptor instead.
func (DNSRecord_RecordType) EnumDescriptor() ([]byte, []int) {
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{11, 0}
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{9, 0}
}
type AddMachineRequest struct {
@@ -339,126 +339,6 @@ func (x *ListMachinesResponse) GetMachines() []*MachineMember {
return nil
}
type UpdateMachineRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Machine to update
MachineId string `protobuf:"bytes,1,opt,name=machine_id,json=machineId,proto3" json:"machine_id,omitempty"`
// Updated machine information
Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"`
PublicIp *IP `protobuf:"bytes,3,opt,name=public_ip,json=publicIp,proto3,oneof" json:"public_ip,omitempty"`
Endpoints []*IPPort `protobuf:"bytes,4,rep,name=endpoints,proto3" json:"endpoints,omitempty"`
}
func (x *UpdateMachineRequest) Reset() {
*x = UpdateMachineRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *UpdateMachineRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateMachineRequest) ProtoMessage() {}
func (x *UpdateMachineRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[4]
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 UpdateMachineRequest.ProtoReflect.Descriptor instead.
func (*UpdateMachineRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{4}
}
func (x *UpdateMachineRequest) GetMachineId() string {
if x != nil {
return x.MachineId
}
return ""
}
func (x *UpdateMachineRequest) GetName() string {
if x != nil && x.Name != nil {
return *x.Name
}
return ""
}
func (x *UpdateMachineRequest) GetPublicIp() *IP {
if x != nil {
return x.PublicIp
}
return nil
}
func (x *UpdateMachineRequest) GetEndpoints() []*IPPort {
if x != nil {
return x.Endpoints
}
return nil
}
type UpdateMachineResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Machine *MachineInfo `protobuf:"bytes,1,opt,name=machine,proto3" json:"machine,omitempty"`
}
func (x *UpdateMachineResponse) Reset() {
*x = UpdateMachineResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *UpdateMachineResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateMachineResponse) ProtoMessage() {}
func (x *UpdateMachineResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[5]
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 UpdateMachineResponse.ProtoReflect.Descriptor instead.
func (*UpdateMachineResponse) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{5}
}
func (x *UpdateMachineResponse) GetMachine() *MachineInfo {
if x != nil {
return x.Machine
}
return nil
}
type RemoveMachineRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -470,7 +350,7 @@ type RemoveMachineRequest struct {
func (x *RemoveMachineRequest) Reset() {
*x = RemoveMachineRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[6]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -483,7 +363,7 @@ func (x *RemoveMachineRequest) String() string {
func (*RemoveMachineRequest) ProtoMessage() {}
func (x *RemoveMachineRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[6]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[4]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -496,7 +376,7 @@ func (x *RemoveMachineRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveMachineRequest.ProtoReflect.Descriptor instead.
func (*RemoveMachineRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{6}
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{4}
}
func (x *RemoveMachineRequest) GetId() string {
@@ -517,7 +397,7 @@ type Domain struct {
func (x *Domain) Reset() {
*x = Domain{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[7]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -530,7 +410,7 @@ func (x *Domain) String() string {
func (*Domain) ProtoMessage() {}
func (x *Domain) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[7]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[5]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -543,7 +423,7 @@ func (x *Domain) ProtoReflect() protoreflect.Message {
// Deprecated: Use Domain.ProtoReflect.Descriptor instead.
func (*Domain) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{7}
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{5}
}
func (x *Domain) GetName() string {
@@ -564,7 +444,7 @@ type ReserveDomainRequest struct {
func (x *ReserveDomainRequest) Reset() {
*x = ReserveDomainRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[8]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -577,7 +457,7 @@ func (x *ReserveDomainRequest) String() string {
func (*ReserveDomainRequest) ProtoMessage() {}
func (x *ReserveDomainRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[8]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[6]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -590,7 +470,7 @@ func (x *ReserveDomainRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ReserveDomainRequest.ProtoReflect.Descriptor instead.
func (*ReserveDomainRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{8}
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{6}
}
func (x *ReserveDomainRequest) GetEndpoint() string {
@@ -611,7 +491,7 @@ type CreateDomainRecordsRequest struct {
func (x *CreateDomainRecordsRequest) Reset() {
*x = CreateDomainRecordsRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[9]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -624,7 +504,7 @@ func (x *CreateDomainRecordsRequest) String() string {
func (*CreateDomainRecordsRequest) ProtoMessage() {}
func (x *CreateDomainRecordsRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[9]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[7]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -637,7 +517,7 @@ func (x *CreateDomainRecordsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateDomainRecordsRequest.ProtoReflect.Descriptor instead.
func (*CreateDomainRecordsRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{9}
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{7}
}
func (x *CreateDomainRecordsRequest) GetRecords() []*DNSRecord {
@@ -658,7 +538,7 @@ type CreateDomainRecordsResponse struct {
func (x *CreateDomainRecordsResponse) Reset() {
*x = CreateDomainRecordsResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[10]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -671,7 +551,7 @@ func (x *CreateDomainRecordsResponse) String() string {
func (*CreateDomainRecordsResponse) ProtoMessage() {}
func (x *CreateDomainRecordsResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[10]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[8]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -684,7 +564,7 @@ func (x *CreateDomainRecordsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateDomainRecordsResponse.ProtoReflect.Descriptor instead.
func (*CreateDomainRecordsResponse) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{10}
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{8}
}
func (x *CreateDomainRecordsResponse) GetRecords() []*DNSRecord {
@@ -707,7 +587,7 @@ type DNSRecord struct {
func (x *DNSRecord) Reset() {
*x = DNSRecord{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[11]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -720,7 +600,7 @@ func (x *DNSRecord) String() string {
func (*DNSRecord) ProtoMessage() {}
func (x *DNSRecord) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[11]
mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[9]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -733,7 +613,7 @@ func (x *DNSRecord) ProtoReflect() protoreflect.Message {
// Deprecated: Use DNSRecord.ProtoReflect.Descriptor instead.
func (*DNSRecord) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{11}
return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{9}
}
func (x *DNSRecord) GetName() string {
@@ -797,87 +677,66 @@ var file_internal_machine_api_pb_cluster_proto_rawDesc = []byte{
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x08, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73,
0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63,
0x68, 0x69, 0x6e, 0x65, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x52, 0x08, 0x6d, 0x61, 0x63, 0x68,
0x69, 0x6e, 0x65, 0x73, 0x22, 0xbb, 0x01, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a,
0x0a, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x04,
0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61,
0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f,
0x69, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49,
0x50, 0x48, 0x01, 0x52, 0x08, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x88, 0x01, 0x01,
0x12, 0x29, 0x0a, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74,
0x52, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f,
0x6e, 0x61, 0x6d, 0x65, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f,
0x69, 0x70, 0x22, 0x43, 0x0a, 0x15, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x63, 0x68,
0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, 0x6d,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61,
0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07,
0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x22, 0x26, 0x0a, 0x14, 0x52, 0x65, 0x6d, 0x6f, 0x76,
0x65, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22,
0x1c, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x32, 0x0a,
0x14, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e,
0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e,
0x74, 0x22, 0x46, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69,
0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x28, 0x0a, 0x07, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x0e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x4e, 0x53, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x52, 0x07, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x22, 0x47, 0x0a, 0x1b, 0x43, 0x72, 0x65,
0x61, 0x74, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, 0x07, 0x72, 0x65, 0x63, 0x6f,
0x72, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x44, 0x4e, 0x53, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x72, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x0e, 0x32, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x4e, 0x53, 0x52, 0x65, 0x63, 0x6f,
0x72, 0x64, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74,
0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20,
0x03, 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x2e, 0x0a, 0x0a, 0x52,
0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0f, 0x0a, 0x0b, 0x55, 0x4e, 0x53,
0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x05, 0x0a, 0x01, 0x41, 0x10,
0x01, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x41, 0x41, 0x41, 0x10, 0x02, 0x32, 0x92, 0x04, 0x0a, 0x07,
0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x3d, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x4d, 0x61,
0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x41, 0x64, 0x64, 0x4d,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x41, 0x64, 0x64, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61,
0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19,
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65,
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x0d, 0x55, 0x70, 0x64,
0x61, 0x74, 0x65, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69,
0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61,
0x74, 0x65, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x42, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4d, 0x61, 0x63, 0x68, 0x69,
0x6e, 0x65, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4d,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e,
0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x37, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65,
0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x73,
0x65, 0x72, 0x76, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x30,
0x0a, 0x09, 0x47, 0x65, 0x74, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d,
0x70, 0x74, 0x79, 0x1a, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
0x12, 0x34, 0x0a, 0x0d, 0x52, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69,
0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x58, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x1f, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20,
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69,
0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70,
0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75,
0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69,
0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x33,
0x69, 0x6e, 0x65, 0x73, 0x22, 0x26, 0x0a, 0x14, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4d, 0x61,
0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x1c, 0x0a, 0x06,
0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x32, 0x0a, 0x14, 0x52, 0x65,
0x73, 0x65, 0x72, 0x76, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x22, 0x46,
0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65,
0x63, 0x6f, 0x72, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x07,
0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x44, 0x4e, 0x53, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x72,
0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x22, 0x47, 0x0a, 0x1b, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, 0x07, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73,
0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x4e, 0x53,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x22,
0x96, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a,
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d,
0x65, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32,
0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x4e, 0x53, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x2e,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65,
0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09,
0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x2e, 0x0a, 0x0a, 0x52, 0x65, 0x63, 0x6f,
0x72, 0x64, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0f, 0x0a, 0x0b, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43,
0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x05, 0x0a, 0x01, 0x41, 0x10, 0x01, 0x12, 0x08,
0x0a, 0x04, 0x41, 0x41, 0x41, 0x41, 0x10, 0x02, 0x32, 0xca, 0x03, 0x0a, 0x07, 0x43, 0x6c, 0x75,
0x73, 0x74, 0x65, 0x72, 0x12, 0x3d, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x4d, 0x61, 0x63, 0x68, 0x69,
0x6e, 0x65, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x41, 0x64, 0x64, 0x4d, 0x61, 0x63, 0x68,
0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69,
0x2e, 0x41, 0x64, 0x64, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69,
0x6e, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x42, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65,
0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65,
0x6d, 0x6f, 0x76, 0x65, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x37, 0x0a, 0x0d, 0x52, 0x65,
0x73, 0x65, 0x72, 0x76, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x19, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x6f, 0x6d,
0x61, 0x69, 0x6e, 0x12, 0x30, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44,
0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x34, 0x0a, 0x0d, 0x52, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65,
0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0b,
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x58, 0x0a, 0x13, 0x43,
0x72, 0x65, 0x61, 0x74, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x73, 0x12, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x44,
0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e,
0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75,
0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f,
0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@@ -893,7 +752,7 @@ func file_internal_machine_api_pb_cluster_proto_rawDescGZIP() []byte {
}
var file_internal_machine_api_pb_cluster_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_internal_machine_api_pb_cluster_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_internal_machine_api_pb_cluster_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_internal_machine_api_pb_cluster_proto_goTypes = []any{
(MachineMember_MembershipState)(0), // 0: api.MachineMember.MembershipState
(DNSRecord_RecordType)(0), // 1: api.DNSRecord.RecordType
@@ -901,54 +760,46 @@ var file_internal_machine_api_pb_cluster_proto_goTypes = []any{
(*AddMachineResponse)(nil), // 3: api.AddMachineResponse
(*MachineMember)(nil), // 4: api.MachineMember
(*ListMachinesResponse)(nil), // 5: api.ListMachinesResponse
(*UpdateMachineRequest)(nil), // 6: api.UpdateMachineRequest
(*UpdateMachineResponse)(nil), // 7: api.UpdateMachineResponse
(*RemoveMachineRequest)(nil), // 8: api.RemoveMachineRequest
(*Domain)(nil), // 9: api.Domain
(*ReserveDomainRequest)(nil), // 10: api.ReserveDomainRequest
(*CreateDomainRecordsRequest)(nil), // 11: api.CreateDomainRecordsRequest
(*CreateDomainRecordsResponse)(nil), // 12: api.CreateDomainRecordsResponse
(*DNSRecord)(nil), // 13: api.DNSRecord
(*NetworkConfig)(nil), // 14: api.NetworkConfig
(*IP)(nil), // 15: api.IP
(*MachineInfo)(nil), // 16: api.MachineInfo
(*IPPort)(nil), // 17: api.IPPort
(*emptypb.Empty)(nil), // 18: google.protobuf.Empty
(*RemoveMachineRequest)(nil), // 6: api.RemoveMachineRequest
(*Domain)(nil), // 7: api.Domain
(*ReserveDomainRequest)(nil), // 8: api.ReserveDomainRequest
(*CreateDomainRecordsRequest)(nil), // 9: api.CreateDomainRecordsRequest
(*CreateDomainRecordsResponse)(nil), // 10: api.CreateDomainRecordsResponse
(*DNSRecord)(nil), // 11: api.DNSRecord
(*NetworkConfig)(nil), // 12: api.NetworkConfig
(*IP)(nil), // 13: api.IP
(*MachineInfo)(nil), // 14: api.MachineInfo
(*emptypb.Empty)(nil), // 15: google.protobuf.Empty
}
var file_internal_machine_api_pb_cluster_proto_depIdxs = []int32{
14, // 0: api.AddMachineRequest.network:type_name -> api.NetworkConfig
15, // 1: api.AddMachineRequest.public_ip:type_name -> api.IP
16, // 2: api.AddMachineResponse.machine:type_name -> api.MachineInfo
16, // 3: api.MachineMember.machine:type_name -> api.MachineInfo
12, // 0: api.AddMachineRequest.network:type_name -> api.NetworkConfig
13, // 1: api.AddMachineRequest.public_ip:type_name -> api.IP
14, // 2: api.AddMachineResponse.machine:type_name -> api.MachineInfo
14, // 3: api.MachineMember.machine:type_name -> api.MachineInfo
0, // 4: api.MachineMember.state:type_name -> api.MachineMember.MembershipState
4, // 5: api.ListMachinesResponse.machines:type_name -> api.MachineMember
15, // 6: api.UpdateMachineRequest.public_ip:type_name -> api.IP
17, // 7: api.UpdateMachineRequest.endpoints:type_name -> api.IPPort
16, // 8: api.UpdateMachineResponse.machine:type_name -> api.MachineInfo
13, // 9: api.CreateDomainRecordsRequest.records:type_name -> api.DNSRecord
13, // 10: api.CreateDomainRecordsResponse.records:type_name -> api.DNSRecord
1, // 11: api.DNSRecord.type:type_name -> api.DNSRecord.RecordType
2, // 12: api.Cluster.AddMachine:input_type -> api.AddMachineRequest
18, // 13: api.Cluster.ListMachines:input_type -> google.protobuf.Empty
6, // 14: api.Cluster.UpdateMachine:input_type -> api.UpdateMachineRequest
8, // 15: api.Cluster.RemoveMachine:input_type -> api.RemoveMachineRequest
10, // 16: api.Cluster.ReserveDomain:input_type -> api.ReserveDomainRequest
18, // 17: api.Cluster.GetDomain:input_type -> google.protobuf.Empty
18, // 18: api.Cluster.ReleaseDomain:input_type -> google.protobuf.Empty
11, // 19: api.Cluster.CreateDomainRecords:input_type -> api.CreateDomainRecordsRequest
3, // 20: api.Cluster.AddMachine:output_type -> api.AddMachineResponse
5, // 21: api.Cluster.ListMachines:output_type -> api.ListMachinesResponse
7, // 22: api.Cluster.UpdateMachine:output_type -> api.UpdateMachineResponse
18, // 23: api.Cluster.RemoveMachine:output_type -> google.protobuf.Empty
9, // 24: api.Cluster.ReserveDomain:output_type -> api.Domain
9, // 25: api.Cluster.GetDomain:output_type -> api.Domain
9, // 26: api.Cluster.ReleaseDomain:output_type -> api.Domain
12, // 27: api.Cluster.CreateDomainRecords:output_type -> api.CreateDomainRecordsResponse
20, // [20:28] is the sub-list for method output_type
12, // [12:20] is the sub-list for method input_type
12, // [12:12] is the sub-list for extension type_name
12, // [12:12] is the sub-list for extension extendee
0, // [0:12] is the sub-list for field type_name
11, // 6: api.CreateDomainRecordsRequest.records:type_name -> api.DNSRecord
11, // 7: api.CreateDomainRecordsResponse.records:type_name -> api.DNSRecord
1, // 8: api.DNSRecord.type:type_name -> api.DNSRecord.RecordType
2, // 9: api.Cluster.AddMachine:input_type -> api.AddMachineRequest
15, // 10: api.Cluster.ListMachines:input_type -> google.protobuf.Empty
6, // 11: api.Cluster.RemoveMachine:input_type -> api.RemoveMachineRequest
8, // 12: api.Cluster.ReserveDomain:input_type -> api.ReserveDomainRequest
15, // 13: api.Cluster.GetDomain:input_type -> google.protobuf.Empty
15, // 14: api.Cluster.ReleaseDomain:input_type -> google.protobuf.Empty
9, // 15: api.Cluster.CreateDomainRecords:input_type -> api.CreateDomainRecordsRequest
3, // 16: api.Cluster.AddMachine:output_type -> api.AddMachineResponse
5, // 17: api.Cluster.ListMachines:output_type -> api.ListMachinesResponse
15, // 18: api.Cluster.RemoveMachine:output_type -> google.protobuf.Empty
7, // 19: api.Cluster.ReserveDomain:output_type -> api.Domain
7, // 20: api.Cluster.GetDomain:output_type -> api.Domain
7, // 21: api.Cluster.ReleaseDomain:output_type -> api.Domain
10, // 22: api.Cluster.CreateDomainRecords:output_type -> api.CreateDomainRecordsResponse
16, // [16:23] is the sub-list for method output_type
9, // [9:16] is the sub-list for method input_type
9, // [9:9] is the sub-list for extension type_name
9, // [9:9] is the sub-list for extension extendee
0, // [0:9] is the sub-list for field type_name
}
func init() { file_internal_machine_api_pb_cluster_proto_init() }
@@ -1008,30 +859,6 @@ func file_internal_machine_api_pb_cluster_proto_init() {
}
}
file_internal_machine_api_pb_cluster_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*UpdateMachineRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_cluster_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*UpdateMachineResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_cluster_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*RemoveMachineRequest); i {
case 0:
return &v.state
@@ -1043,7 +870,7 @@ func file_internal_machine_api_pb_cluster_proto_init() {
return nil
}
}
file_internal_machine_api_pb_cluster_proto_msgTypes[7].Exporter = func(v any, i int) any {
file_internal_machine_api_pb_cluster_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*Domain); i {
case 0:
return &v.state
@@ -1055,7 +882,7 @@ func file_internal_machine_api_pb_cluster_proto_init() {
return nil
}
}
file_internal_machine_api_pb_cluster_proto_msgTypes[8].Exporter = func(v any, i int) any {
file_internal_machine_api_pb_cluster_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*ReserveDomainRequest); i {
case 0:
return &v.state
@@ -1067,7 +894,7 @@ func file_internal_machine_api_pb_cluster_proto_init() {
return nil
}
}
file_internal_machine_api_pb_cluster_proto_msgTypes[9].Exporter = func(v any, i int) any {
file_internal_machine_api_pb_cluster_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*CreateDomainRecordsRequest); i {
case 0:
return &v.state
@@ -1079,7 +906,7 @@ func file_internal_machine_api_pb_cluster_proto_init() {
return nil
}
}
file_internal_machine_api_pb_cluster_proto_msgTypes[10].Exporter = func(v any, i int) any {
file_internal_machine_api_pb_cluster_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*CreateDomainRecordsResponse); i {
case 0:
return &v.state
@@ -1091,7 +918,7 @@ func file_internal_machine_api_pb_cluster_proto_init() {
return nil
}
}
file_internal_machine_api_pb_cluster_proto_msgTypes[11].Exporter = func(v any, i int) any {
file_internal_machine_api_pb_cluster_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*DNSRecord); i {
case 0:
return &v.state
@@ -1104,14 +931,13 @@ func file_internal_machine_api_pb_cluster_proto_init() {
}
}
}
file_internal_machine_api_pb_cluster_proto_msgTypes[4].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_internal_machine_api_pb_cluster_proto_rawDesc,
NumEnums: 2,
NumMessages: 12,
NumMessages: 10,
NumExtensions: 0,
NumServices: 1,
},
-15
View File
@@ -11,7 +11,6 @@ import "internal/machine/api/pb/machine.proto";
service Cluster {
rpc AddMachine(AddMachineRequest) returns (AddMachineResponse);
rpc ListMachines(google.protobuf.Empty) returns (ListMachinesResponse);
rpc UpdateMachine(UpdateMachineRequest) returns (UpdateMachineResponse);
rpc RemoveMachine(RemoveMachineRequest) returns (google.protobuf.Empty);
rpc ReserveDomain(ReserveDomainRequest) returns (Domain);
@@ -51,20 +50,6 @@ message ListMachinesResponse {
repeated MachineMember machines = 1;
}
message UpdateMachineRequest {
// Machine to update
string machine_id = 1;
// Updated machine information
optional string name = 2;
optional IP public_ip = 3;
repeated IPPort endpoints = 4;
}
message UpdateMachineResponse {
MachineInfo machine = 1;
}
message RemoveMachineRequest {
string id = 1;
}
@@ -22,7 +22,6 @@ const _ = grpc.SupportPackageIsVersion9
const (
Cluster_AddMachine_FullMethodName = "/api.Cluster/AddMachine"
Cluster_ListMachines_FullMethodName = "/api.Cluster/ListMachines"
Cluster_UpdateMachine_FullMethodName = "/api.Cluster/UpdateMachine"
Cluster_RemoveMachine_FullMethodName = "/api.Cluster/RemoveMachine"
Cluster_ReserveDomain_FullMethodName = "/api.Cluster/ReserveDomain"
Cluster_GetDomain_FullMethodName = "/api.Cluster/GetDomain"
@@ -36,7 +35,6 @@ const (
type ClusterClient interface {
AddMachine(ctx context.Context, in *AddMachineRequest, opts ...grpc.CallOption) (*AddMachineResponse, error)
ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListMachinesResponse, error)
UpdateMachine(ctx context.Context, in *UpdateMachineRequest, opts ...grpc.CallOption) (*UpdateMachineResponse, error)
RemoveMachine(ctx context.Context, in *RemoveMachineRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
ReserveDomain(ctx context.Context, in *ReserveDomainRequest, opts ...grpc.CallOption) (*Domain, error)
GetDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error)
@@ -72,16 +70,6 @@ func (c *clusterClient) ListMachines(ctx context.Context, in *emptypb.Empty, opt
return out, nil
}
func (c *clusterClient) UpdateMachine(ctx context.Context, in *UpdateMachineRequest, opts ...grpc.CallOption) (*UpdateMachineResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateMachineResponse)
err := c.cc.Invoke(ctx, Cluster_UpdateMachine_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clusterClient) RemoveMachine(ctx context.Context, in *RemoveMachineRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
@@ -138,7 +126,6 @@ func (c *clusterClient) CreateDomainRecords(ctx context.Context, in *CreateDomai
type ClusterServer interface {
AddMachine(context.Context, *AddMachineRequest) (*AddMachineResponse, error)
ListMachines(context.Context, *emptypb.Empty) (*ListMachinesResponse, error)
UpdateMachine(context.Context, *UpdateMachineRequest) (*UpdateMachineResponse, error)
RemoveMachine(context.Context, *RemoveMachineRequest) (*emptypb.Empty, error)
ReserveDomain(context.Context, *ReserveDomainRequest) (*Domain, error)
GetDomain(context.Context, *emptypb.Empty) (*Domain, error)
@@ -160,9 +147,6 @@ func (UnimplementedClusterServer) AddMachine(context.Context, *AddMachineRequest
func (UnimplementedClusterServer) ListMachines(context.Context, *emptypb.Empty) (*ListMachinesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListMachines not implemented")
}
func (UnimplementedClusterServer) UpdateMachine(context.Context, *UpdateMachineRequest) (*UpdateMachineResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateMachine not implemented")
}
func (UnimplementedClusterServer) RemoveMachine(context.Context, *RemoveMachineRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveMachine not implemented")
}
@@ -235,24 +219,6 @@ func _Cluster_ListMachines_Handler(srv interface{}, ctx context.Context, dec fun
return interceptor(ctx, in, info, handler)
}
func _Cluster_UpdateMachine_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateMachineRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClusterServer).UpdateMachine(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Cluster_UpdateMachine_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClusterServer).UpdateMachine(ctx, req.(*UpdateMachineRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Cluster_RemoveMachine_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveMachineRequest)
if err := dec(in); err != nil {
@@ -358,10 +324,6 @@ var Cluster_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListMachines",
Handler: _Cluster_ListMachines_Handler,
},
{
MethodName: "UpdateMachine",
Handler: _Cluster_UpdateMachine_Handler,
},
{
MethodName: "RemoveMachine",
Handler: _Cluster_RemoveMachine_Handler,
+73 -50
View File
@@ -81,8 +81,12 @@ type Metadata struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// ID of the machine the response came from.
MachineId string `protobuf:"bytes,4,opt,name=machine_id,json=machineId,proto3" json:"machine_id,omitempty"`
// Name of the machine the response came from.
MachineName string `protobuf:"bytes,5,opt,name=machine_name,json=machineName,proto3" json:"machine_name,omitempty"`
// Address of the machine the response came from.
Machine string `protobuf:"bytes,1,opt,name=machine,proto3" json:"machine,omitempty"`
MachineAddr string `protobuf:"bytes,1,opt,name=machine_addr,json=machineAddr,proto3" json:"machine_addr,omitempty"`
// error is set if the request to upstream failed. The rest of the response is undefined.
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
// error as a gRPC Status message.
@@ -121,9 +125,23 @@ func (*Metadata) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{0}
}
func (x *Metadata) GetMachine() string {
func (x *Metadata) GetMachineId() string {
if x != nil {
return x.Machine
return x.MachineId
}
return ""
}
func (x *Metadata) GetMachineName() string {
if x != nil {
return x.MachineName
}
return ""
}
func (x *Metadata) GetMachineAddr() string {
if x != nil {
return x.MachineAddr
}
return ""
}
@@ -549,53 +567,58 @@ var file_internal_machine_api_pb_common_proto_rawDesc = []byte{
0x67, 0x6c, 0x65, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x66, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
0x61, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65,
0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f,
0x72, 0x12, 0x2a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x12, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53,
0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x32, 0x0a,
0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61,
0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d,
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
0x61, 0x22, 0x37, 0x0a, 0x0d, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x26, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01,
0x20, 0x03, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x14, 0x0a, 0x02, 0x49, 0x50,
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70,
0x22, 0x35, 0x0a, 0x06, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70,
0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52,
0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28,
0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x37, 0x0a, 0x08, 0x49, 0x50, 0x50, 0x72, 0x65,
0x66, 0x69, 0x78, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04,
0x62, 0x69, 0x74, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x62, 0x69, 0x74, 0x73,
0x22, 0x75, 0x0a, 0x0b, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12,
0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x6c, 0x6f, 0x77, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52,
0x06, 0x66, 0x6f, 0x6c, 0x6c, 0x6f, 0x77, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x69, 0x6c, 0x18,
0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x73,
0x69, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63,
0x65, 0x12, 0x14, 0x0a, 0x05, 0x75, 0x6e, 0x74, 0x69, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
0x52, 0x05, 0x75, 0x6e, 0x74, 0x69, 0x6c, 0x22, 0xd2, 0x01, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x45,
0x6e, 0x74, 0x72, 0x79, 0x12, 0x30, 0x0a, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x45, 0x6e,
0x74, 0x72, 0x79, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x52, 0x06,
0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74,
0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65,
0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
0x0c, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x40, 0x0a, 0x0a, 0x53, 0x74,
0x72, 0x65, 0x61, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e,
0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x44, 0x4f, 0x55, 0x54, 0x10,
0x01, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x44, 0x45, 0x52, 0x52, 0x10, 0x02, 0x12, 0x0d, 0x0a,
0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x03, 0x42, 0x37, 0x5a, 0x35,
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64,
0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e,
0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61,
0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xb1, 0x01, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
0x74, 0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64,
0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49,
0x64, 0x12, 0x21, 0x0a, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x6e, 0x61, 0x6d,
0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65,
0x4e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f,
0x61, 0x64, 0x64, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68,
0x69, 0x6e, 0x65, 0x41, 0x64, 0x64, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x2a, 0x0a,
0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e,
0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75,
0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x32, 0x0a, 0x05, 0x45, 0x6d, 0x70,
0x74, 0x79, 0x12, 0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64,
0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0x37, 0x0a,
0x0d, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26,
0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x0a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x52, 0x08, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x14, 0x0a, 0x02, 0x49, 0x50, 0x12, 0x0e, 0x0a, 0x02,
0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70, 0x22, 0x35, 0x0a, 0x06,
0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x02, 0x69, 0x70, 0x12,
0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70,
0x6f, 0x72, 0x74, 0x22, 0x37, 0x0a, 0x08, 0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12,
0x17, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x49, 0x50, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x69, 0x74, 0x73,
0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x62, 0x69, 0x74, 0x73, 0x22, 0x75, 0x0a, 0x0b,
0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x66,
0x6f, 0x6c, 0x6c, 0x6f, 0x77, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x66, 0x6f, 0x6c,
0x6c, 0x6f, 0x77, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28,
0x05, 0x52, 0x04, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65,
0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x12, 0x14, 0x0a,
0x05, 0x75, 0x6e, 0x74, 0x69, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x75, 0x6e,
0x74, 0x69, 0x6c, 0x22, 0xd2, 0x01, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79,
0x12, 0x30, 0x0a, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e,
0x32, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x2e,
0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x52, 0x06, 0x73, 0x74, 0x72, 0x65,
0x61, 0x6d, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x18, 0x0a, 0x07,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x40, 0x0a, 0x0a, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d,
0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10,
0x00, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x44, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x12, 0x0a, 0x0a,
0x06, 0x53, 0x54, 0x44, 0x45, 0x52, 0x52, 0x10, 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x48, 0x45, 0x41,
0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x03, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68,
0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b,
0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70,
0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
+6 -1
View File
@@ -11,8 +11,13 @@ import "google/protobuf/timestamp.proto";
// Common metadata message nested in all reply message types, injected by the gRPC proxy to provide information
// about the machine that responded to the request.
message Metadata {
// ID of the machine the response came from.
string machine_id = 4;
// Name of the machine the response came from.
string machine_name = 5;
// Address of the machine the response came from.
string machine = 1;
string machine_addr = 1;
// error is set if the request to upstream failed. The rest of the response is undefined.
string error = 2;
// error as a gRPC Status message.
File diff suppressed because it is too large Load Diff
+40 -4
View File
@@ -19,6 +19,8 @@ service Machine {
rpc Inspect(google.protobuf.Empty) returns (MachineInfo);
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse);
// UpdateMachine updates the configuration of the machine.
rpc UpdateMachine(UpdateMachineRequest) returns (UpdateMachineResponse);
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
rpc InspectWireGuardNetwork(google.protobuf.Empty) returns (InspectWireGuardNetworkResponse);
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
@@ -34,6 +36,19 @@ message MachineInfo {
string name = 2;
NetworkConfig network = 3;
IP public_ip = 4;
// Version of the machine daemon (uncloudd).
string daemon_version = 7;
// Version of the Docker engine running on the machine. Could be empty if unable to get.
string docker_version = 8;
// Operating system hostname.
string hostname = 5;
// CPU architecture.
string arch = 6;
// Human-readable operating system name and version, e.g. "Ubuntu 24.04.4 LTS". Empty if cannot be determined.
string os_pretty_name = 9;
// Kernel release version, e.g. "6.8.0-31-generic".
string kernel_version = 10;
}
message NetworkConfig {
@@ -43,6 +58,17 @@ message NetworkConfig {
bytes public_key = 4;
}
message UpdateMachineRequest {
// Updated machine information. Only the set fields are applied.
optional string name = 1;
optional IP public_ip = 2;
repeated IPPort endpoints = 3;
}
message UpdateMachineResponse {
MachineInfo machine = 1;
}
message CheckPrerequisitesResponse {
// Overall status of the checks.
bool satisfied = 1;
@@ -61,6 +87,10 @@ message InitClusterRequest {
// Optional WireGuard endpoints other machines will use to connect to this machine instead of auto-discovered ones.
repeated IPPort wireguard_endpoints = 5;
// WireGuard listen port for this machine. Uses the default port (51820) if 0 or not set.
int32 wireguard_port = 6;
// MTU of the WireGuard interface on this machine. The daemon auto-detects the optimal MTU if 0 or not set.
int32 wireguard_mtu = 7;
}
message InitClusterResponse {
@@ -70,8 +100,13 @@ message InitClusterResponse {
message JoinClusterRequest {
MachineInfo machine = 1;
repeated MachineInfo other_machines = 3;
// Minimum store database version the new machine should sync to before starting cluster operations.
int64 min_store_db_version = 4;
// WireGuard listen port for this machine. Uses the default port (51820) if 0 or not set.
int32 wireguard_port = 5;
// MTU of the WireGuard interface on this machine. The daemon auto-detects the optimal MTU if 0 or not set.
int32 wireguard_mtu = 7;
// Cluster store version this machine must reach before participating.
// Per-actor vector (Corrosion actor UUID → max applied db_version).
map<string, int64> min_store_version = 6;
}
message InspectMachineResponse {
@@ -82,10 +117,11 @@ message InspectMachineResponse {
message MachineDetails {
Metadata metadata = 1;
MachineInfo machine = 2;
// Current Corrosion cr-sqlite database version (Lamport timestamp) of the cluster store.
int64 store_db_version = 3;
// Round-trip times to other machines in the cluster, keyed by peer machine ID.
map<string, RTTStats> rtts = 4;
// Current cluster store version observed on this machine.
// Per-actor vector (Corrosion actor UUID → max applied db_version) read from Corrosion crsql_db_versions.
map<string, int64> store_version = 5;
}
message TokenResponse {
@@ -26,6 +26,7 @@ const (
Machine_Token_FullMethodName = "/api.Machine/Token"
Machine_Inspect_FullMethodName = "/api.Machine/Inspect"
Machine_InspectMachine_FullMethodName = "/api.Machine/InspectMachine"
Machine_UpdateMachine_FullMethodName = "/api.Machine/UpdateMachine"
Machine_InspectWireGuardNetwork_FullMethodName = "/api.Machine/InspectWireGuardNetwork"
Machine_Reset_FullMethodName = "/api.Machine/Reset"
Machine_InspectService_FullMethodName = "/api.Machine/InspectService"
@@ -45,6 +46,8 @@ type MachineClient interface {
Inspect(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*MachineInfo, error)
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error)
// UpdateMachine updates the configuration of the machine.
UpdateMachine(ctx context.Context, in *UpdateMachineRequest, opts ...grpc.CallOption) (*UpdateMachineResponse, error)
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
InspectWireGuardNetwork(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectWireGuardNetworkResponse, error)
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
@@ -121,6 +124,16 @@ func (c *machineClient) InspectMachine(ctx context.Context, in *emptypb.Empty, o
return out, nil
}
func (c *machineClient) UpdateMachine(ctx context.Context, in *UpdateMachineRequest, opts ...grpc.CallOption) (*UpdateMachineResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateMachineResponse)
err := c.cc.Invoke(ctx, Machine_UpdateMachine_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *machineClient) InspectWireGuardNetwork(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectWireGuardNetworkResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(InspectWireGuardNetworkResponse)
@@ -183,6 +196,8 @@ type MachineServer interface {
Inspect(context.Context, *emptypb.Empty) (*MachineInfo, error)
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error)
// UpdateMachine updates the configuration of the machine.
UpdateMachine(context.Context, *UpdateMachineRequest) (*UpdateMachineResponse, error)
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
InspectWireGuardNetwork(context.Context, *emptypb.Empty) (*InspectWireGuardNetworkResponse, error)
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
@@ -217,6 +232,9 @@ func (UnimplementedMachineServer) Inspect(context.Context, *emptypb.Empty) (*Mac
func (UnimplementedMachineServer) InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method InspectMachine not implemented")
}
func (UnimplementedMachineServer) UpdateMachine(context.Context, *UpdateMachineRequest) (*UpdateMachineResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateMachine not implemented")
}
func (UnimplementedMachineServer) InspectWireGuardNetwork(context.Context, *emptypb.Empty) (*InspectWireGuardNetworkResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method InspectWireGuardNetwork not implemented")
}
@@ -358,6 +376,24 @@ func _Machine_InspectMachine_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _Machine_UpdateMachine_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateMachineRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MachineServer).UpdateMachine(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Machine_UpdateMachine_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MachineServer).UpdateMachine(ctx, req.(*UpdateMachineRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Machine_InspectWireGuardNetwork_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(emptypb.Empty)
if err := dec(in); err != nil {
@@ -454,6 +490,10 @@ var Machine_ServiceDesc = grpc.ServiceDesc{
MethodName: "InspectMachine",
Handler: _Machine_InspectMachine_Handler,
},
{
MethodName: "UpdateMachine",
Handler: _Machine_UpdateMachine_Handler,
},
{
MethodName: "InspectWireGuardNetwork",
Handler: _Machine_InspectWireGuardNetwork_Handler,

Some files were not shown because too many files have changed in this diff Show More