Compare commits

..
1 Commits
Author SHA1 Message Date
Pasha Sviderski e698f6d06e draft hub landing 2026-05-15 17:56:47 +10:00
395 changed files with 9722 additions and 40091 deletions
-1
View File
@@ -2,7 +2,6 @@
* *
# Allow files and directories. # Allow files and directories.
!api/
!cmd/ !cmd/
!internal/ !internal/
!pkg/ !pkg/
-3
View File
@@ -18,6 +18,3 @@ indent_size = 4
[*.sh] [*.sh]
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
[release-notes-*.md]
max_line_length = off
+4 -5
View File
@@ -14,13 +14,12 @@ on:
- "**.proto" - "**.proto"
- "go.*" - "go.*"
- "Makefile" - "Makefile"
- "mise.toml"
- "scripts/**" - "scripts/**"
- "test/**" - "test/**"
permissions: permissions:
contents: read contents: read
env: env:
MISE_VERSION: "2026.10.22" MISE_VERSION: "2026.3.7"
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -43,9 +42,9 @@ jobs:
- name: Generate docs - name: Generate docs
run: | run: |
mise run docs:cli make cli-docs
git diff --exit-code || git diff --exit-code ||
(echo "Documentation is out of date. Please run 'mise run docs:cli' and commit the changes." && exit 1) (echo "Documentation is out of date. Please run 'make cli-docs' and commit the changes." && exit 1)
- name: Run tests - name: Run tests
run: | run: |
@@ -71,7 +70,7 @@ jobs:
version: ${{ env.MISE_VERSION }} version: ${{ env.MISE_VERSION }}
# TODO: figure out how to properly lock all platforms in the lockfile and remove this hack. # TODO: figure out how to properly lock all platforms in the lockfile and remove this hack.
# `mise lock` doesn't generate checksums for some of the tools, e.g. protoc* # `mise lock` (mise run mise:lock) doesn't generate checksums for some of the tools, e.g. protoc*
- name: Reset changes in mise.lock after fresh mise install - name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock run: git checkout mise.lock
+2 -2
View File
@@ -14,7 +14,7 @@ on:
permissions: permissions:
contents: read contents: read
env: env:
MISE_VERSION: "2026.10.22" MISE_VERSION: "2026.3.7"
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -30,6 +30,6 @@ jobs:
run: git checkout mise.lock run: git checkout mise.lock
- name: Lint - name: Lint
run: mise run lint run: make lint
timeout-minutes: 10 timeout-minutes: 10
+2 -3
View File
@@ -8,7 +8,7 @@ permissions:
contents: write contents: write
env: env:
MISE_VERSION: "2026.10.22" MISE_VERSION: "2026.3.7"
jobs: jobs:
nightly: nightly:
@@ -57,8 +57,7 @@ jobs:
The commands below show how to install the nightly version of uncloud. The commands below show how to install the nightly version of uncloud.
> [!WARNING] > **Warning**: These are nightly development builds and may be unstable.
> These are nightly development builds and may be unstable.
**Install uncloud CLI:** **Install uncloud CLI:**
-1
View File
@@ -44,4 +44,3 @@ node_modules/
.devcontainer/ .devcontainer/
.zed/ .zed/
research/
+10 -19
View File
@@ -20,9 +20,9 @@ snapshot:
version_template: "{{ incminor .Version }}-nightly-{{.ShortCommit}}" version_template: "{{ incminor .Version }}-nightly-{{.ShortCommit}}"
builds: builds:
- id: uc - id: uncloud
main: ./cmd/uc main: ./cmd/uncloud
binary: uc binary: uncloud
env: env:
# Use CGO to be able to build fsevent required by compose on macOS. # Use CGO to be able to build fsevent required by compose on macOS.
- CGO_ENABLED={{ if eq .Os "darwin" }}1{{ else }}0{{ end }} - CGO_ENABLED={{ if eq .Os "darwin" }}1{{ else }}0{{ end }}
@@ -34,12 +34,7 @@ builds:
- amd64 - amd64
- arm64 - arm64
ldflags: ldflags:
- -s -w - -s -w -X github.com/psviderski/uncloud/internal/version.version={{ .Version }}
-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 - id: uncloudd
main: ./cmd/uncloudd main: ./cmd/uncloudd
@@ -52,17 +47,12 @@ builds:
- amd64 - amd64
- arm64 - arm64
ldflags: ldflags:
- -s -w - -s -w -X github.com/psviderski/uncloud/internal/version.version={{ .Version }}
-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: archives:
- id: uc - id: uncloud
builds: builds:
- uc - uncloud
formats: formats:
# gz is not compatible with homebrew releases, so we use tar.gz # gz is not compatible with homebrew releases, so we use tar.gz
- tar.gz - tar.gz
@@ -103,9 +93,10 @@ brews:
description: "Uncloud CLI" description: "Uncloud CLI"
homepage: "https://uncloud.run" homepage: "https://uncloud.run"
ids: ids:
- uc - uncloud
install: | install: |
bin.install "uc" bin.install "uncloud"
bin.install_symlink "uncloud" => "uc"
skip_upload: false skip_upload: false
repository: repository:
owner: psviderski owner: psviderski
-6
View File
@@ -1,10 +1,4 @@
packages: packages:
github.com/docker/docker/client:
config:
dir: internal/machine/docker
pkgname: docker
interfaces:
APIClient:
github.com/psviderski/uncloud/internal/machine/caddyconfig: github.com/psviderski/uncloud/internal/machine/caddyconfig:
interfaces: interfaces:
CaddyfileValidator: CaddyfileValidator:
+36 -10
View File
@@ -72,7 +72,7 @@ management.
- **`cmd/`**: Contains main applications - **`cmd/`**: Contains main applications
- `uc/`: CLI tool with subcommands for machine, service, volume management - `uncloud/`: CLI tool with subcommands for machine, service, volume management
- `uncloudd/`: Daemon that runs on each machine - `uncloudd/`: Daemon that runs on each machine
- `ucind/`: Development cluster management for testing - `ucind/`: Development cluster management for testing
@@ -98,28 +98,53 @@ management.
- **`misc/`**: Design documents and guides - **`misc/`**: Design documents and guides
## Key Technologies
### Core Dependencies
```go
// Networking and orchestration
github.com/docker/docker // Docker API client
github.com/docker/compose/v2 // Docker Compose integration
golang.zx2c4.com/wireguard // WireGuard implementation
github.com/hashicorp/serf // Gossip protocol
// State management
github.com/ipfs/go-ds-crdt // CRDT distributed storage
github.com/dgraph-io/badger/v3 // Embedded database
// Web proxy
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
// CLI and UX
github.com/spf13/cobra // CLI framework
github.com/charmbracelet/huh // Interactive forms
// gRPC and networking
google.golang.org/grpc // gRPC framework
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
```
## Development Workflow ## Development Workflow
### Build and Development ### Build and Development
```bash ```bash
# Build binaries # Build binaries
go build -o uc ./cmd/uc go build -o uncloud ./cmd/uncloud
go build -o uncloudd ./cmd/uncloudd go build -o uncloudd ./cmd/uncloudd
``` ```
### Formatting and Linting ### Key Make Targets
- `mise run lint`: Check Go code and formatting without changing files
- `mise run lint:fix`: Apply formatting and available lint fixes
### Other Development Tasks
- `proto`: Generate protobuf code - `proto`: Generate protobuf code
- `ucind-cluster`: Create development cluster - `ucind-cluster`: Create development cluster
- `update-dev`: Deploy to development machines - `update-dev`: Deploy to development machines
- `demo-reset`: Reset demo environment - `demo-reset`: Reset demo environment
- `fmt`: Format code
- `test`: Run all tests - `test`: Run all tests
- `lint`: Lint the code using golangci-lint
- `lint-and-fix`: Lint the code and fix issues whenever possible
## CLI Commands Structure ## CLI Commands Structure
@@ -163,7 +188,7 @@ uc context use <name> # Switch context
- **Package naming**: Use clear, descriptive names - **Package naming**: Use clear, descriptive names
- **Error handling**: Wrap errors with context using `fmt.Errorf` - **Error handling**: Wrap errors with context using `fmt.Errorf`
- **Logging**: Use structured logging with levels - **Logging**: Use structured logging with levels
- **gRPC**: Services defined in `api/pb/` - **gRPC**: Services defined in `internal/machine/api/pb/`
### Testing ### Testing
@@ -171,6 +196,7 @@ uc context use <name> # Switch context
- Unit tests alongside source files (`*_test.go`) - Unit tests alongside source files (`*_test.go`)
- Integration tests in `test/e2e/` - Integration tests in `test/e2e/`
- Test fixtures in `test/fixtures/` - Test fixtures in `test/fixtures/`
- Use table driven tests whenever possible
- Use the `testify` library for assertions (e.g., `require.Equal`, `assert.Nil`) - Use the `testify` library for assertions (e.g., `require.Equal`, `assert.Nil`)
### Dependencies ### Dependencies
@@ -213,7 +239,7 @@ uc context use <name> # Switch context
### Important Files to Understand ### Important Files to Understand
- `cmd/uc/main.go`: CLI entry point and command structure - `cmd/uncloud/main.go`: CLI entry point and command structure
- `internal/cli/cli.go`: CLI implementation and configuration - `internal/cli/cli.go`: CLI implementation and configuration
- `internal/machine/machine.go`: Core machine management - `internal/machine/machine.go`: Core machine management
- `pkg/api/`: Public API definitions - `pkg/api/`: Public API definitions
+5 -9
View File
@@ -18,7 +18,6 @@ RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd ./cmd/uncloudd
FROM alpine:${ALPINE_VERSION} AS corrosion-download FROM alpine:${ALPINE_VERSION} AS corrosion-download
ARG TARGETARCH ARG TARGETARCH
ARG CORROSION_VERSION
RUN CORROSION_ARCH=$(case "${TARGETARCH}" in \ RUN CORROSION_ARCH=$(case "${TARGETARCH}" in \
"amd64") echo "x86_64" ;; \ "amd64") echo "x86_64" ;; \
@@ -26,21 +25,20 @@ RUN CORROSION_ARCH=$(case "${TARGETARCH}" in \
*) echo "Architecture '${TARGETARCH}' not supported" >&2 && exit 1 ;; \ *) echo "Architecture '${TARGETARCH}' not supported" >&2 && exit 1 ;; \
esac) \ esac) \
&& wget -q -O /tmp/corrosion.tar.gz \ && wget -q -O /tmp/corrosion.tar.gz \
"https://github.com/psviderski/corrosion/releases/download/v${CORROSION_VERSION}/corrosion-${CORROSION_ARCH}-unknown-linux-gnu.tar.gz" \ "https://github.com/psviderski/corrosion/releases/latest/download/corrosion-${CORROSION_ARCH}-unknown-linux-gnu.tar.gz" \
&& tar -xzf /tmp/corrosion.tar.gz -C /tmp \ && tar -xzf /tmp/corrosion.tar.gz -C /tmp \
&& install /tmp/corrosion /usr/bin/corrosion \ && install /tmp/corrosion /usr/local/bin/corrosion \
&& rm /tmp/corrosion.tar.gz /tmp/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, # 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. # 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-debian13:latest AS corrosion FROM gcr.io/distroless/cc-debian12:latest AS corrosion
COPY --from=corrosion-download /usr/bin/corrosion /usr/bin/corrosion COPY --from=corrosion-download /usr/local/bin/corrosion /usr/local/bin/corrosion
CMD ["corrosion", "agent"] CMD ["corrosion", "agent"]
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
ARG CORROSION_VERSION ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
ARG CORROSION_IMAGE="ghcr.io/unlabs-dev/corrosion:${CORROSION_VERSION}"
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@@ -49,8 +47,6 @@ RUN crane pull --platform ${TARGETOS}/${TARGETARCH} "${CORROSION_IMAGE}" /corros
# Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker. # Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker.
FROM docker:29.4.0-dind AS ucind FROM docker:29.4.0-dind AS ucind
ARG CORROSION_VERSION
# Create system group and user 'uncloud'. # Create system group and user 'uncloud'.
RUN addgroup -S uncloud && adduser -SHD -h /nonexistent -G uncloud -g "" uncloud RUN addgroup -S uncloud && adduser -SHD -h /nonexistent -G uncloud -g "" uncloud
RUN apk --no-cache add \ RUN apk --no-cache add \
+7 -36
View File
@@ -32,14 +32,13 @@ available outside the project, so they don't interfere with system packages.
Build the CLI: Build the CLI:
```shell ```shell
go build -o uc ./cmd/uc go build -o uc ./cmd/uncloud
``` ```
Or build and run the CLI with mise. It forwards arguments, including help flags, directly to the CLI: Or build and run the CLI with a single command:
```shell ```shell
mise uc --help go run ./cmd/uncloud --help
mise uc image rm -h
``` ```
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
@@ -100,21 +99,15 @@ mise run dev:reset user@host1 user@host2
## Linting and formatting ## Linting and formatting
Check the code and formatting without changing files: Lint and format the code:
```shell ```shell
mise run lint make lint-and-fix
```
Apply formatting and available lint fixes:
```shell
mise run lint:fix
``` ```
## Code generation ## Code generation
Update the generated Go code for the gRPC API after modifying files in [`api/pb`](./api/pb): Update the generated Go code for the machine gRPC API after modifying `.proto` files:
```shell ```shell
mise run proto mise run proto
@@ -123,31 +116,9 @@ mise run proto
Regenerate mocks: Regenerate mocks:
```shell ```shell
mise run mocks make mocks
``` ```
## Documentation
Regenerate the CLI reference docs after changing commands or flags:
```shell
mise run docs:cli
```
To preview the documentation website, install its dependencies from the project root:
```shell
npm ci --prefix website
```
Then start the local development server:
```shell
mise run docs:dev
```
The server opens the site (http://localhost:3000/) in your browser and reloads it as you edit files in `website/`.
## Uncloud in Docker (UCinD) ## Uncloud in Docker (UCinD)
The [`ucind`](./cmd/ucind) CLI lets you run Uncloud clusters locally using Docker containers instead of real machines. The [`ucind`](./cmd/ucind) CLI lets you run Uncloud clusters locally using Docker containers instead of real machines.
+29 -5
View File
@@ -1,7 +1,6 @@
# TODO: Makefile is deprecated, add new targets as mise tasks in mise.toml instead. # TODO: Makefile is deprecated, add new targets as mise tasks in mise.toml instead.
CORROSION_VERSION ?= 2026.6.15 CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
CORROSION_IMAGE = ghcr.io/unlabs-dev/corrosion:$(CORROSION_VERSION)
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
.PHONY: ucind-cluster .PHONY: ucind-cluster
@@ -10,17 +9,20 @@ ucind-cluster:
.PHONY: corrosion-image .PHONY: corrosion-image
corrosion-image: corrosion-image:
docker build --build-arg CORROSION_VERSION=$(CORROSION_VERSION) -t "$(CORROSION_IMAGE)" --target corrosion . docker build -t "$(CORROSION_IMAGE)" --target corrosion .
.PHONY: corrosion-multiarch-image-push .PHONY: corrosion-multiarch-image-push
corrosion-multiarch-image-push: corrosion-multiarch-image-push:
docker buildx build --build-arg CORROSION_VERSION=$(CORROSION_VERSION) --push --platform linux/amd64,linux/arm64 \ docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
-t "$(CORROSION_IMAGE)" --target corrosion .
.PHONY: ucind-multiarch-image-push .PHONY: ucind-multiarch-image-push
ucind-multiarch-image-push: ucind-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind . docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
.PHONY: mocks
mocks:
@mockery
.PHONY: test .PHONY: test
test: test:
ifeq ($(TEST_NAME),) ifeq ($(TEST_NAME),)
@@ -47,3 +49,25 @@ test-clean:
else \ else \
echo "Nothing to clean."; \ echo "Nothing to clean."; \
fi fi
.PHONY: vet
vet:
go vet ./...
.PHONY: format fmt
format fmt:
GOOS=linux golangci-lint fmt
LINT_TARGETS := lint lint-and-fix
.PHONY: $(LINT_TARGETS) _lint
$(LINT_TARGETS): _lint
lint: ARGS=
lint-and-fix: ARGS=--fix
_lint:
# Explicitly set OS to Linux to not skip *_linux.go files when running on macOS.
# Uncloud daemon won't likely support OS other than Linux anytime soon, so for now we can rely on that.
GOOS=linux golangci-lint run $(ARGS)
.PHONY: cli-docs
cli-docs:
go run ./cmd/uncloud docs
+18 -17
View File
@@ -44,8 +44,6 @@ complexity of Kubernetes.
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service. [Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's * **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
Encrypt. Encrypt.
* **Cluster storage for TLS certificates**: [Cluster storage](https://github.com/unlabs-dev/caddy-uncloud) for Caddy
shares certificates and ACME challenge tokens across machines and coordinates issuance.
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications. * **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster. * **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
@@ -156,6 +154,7 @@ Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
```bash ```bash
$ uc machine init --name oracle-vm ubuntu@152.67.101.197 $ 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... ⏳ Running Uncloud install script...
✓ Docker is already installed. ✓ Docker is already installed.
⏳ Installing Docker... ⏳ Installing Docker...
@@ -168,10 +167,11 @@ $ uc machine init --name oracle-vm ubuntu@152.67.101.197
✓ uncloudd binary installed: /usr/local/bin/uncloudd ✓ uncloudd binary installed: /usr/local/bin/uncloudd
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh ⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall ✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
✓ Systemd unit file created: /etc/systemd/system/uncloud.socket
✓ Systemd unit file created: /etc/systemd/system/uncloud.service ✓ Systemd unit file created: /etc/systemd/system/uncloud.service
Created symlink /etc/systemd/system/sockets.target.wants/uncloud.socket → /etc/systemd/system/uncloud.socket.
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /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)... ⏳ Starting Uncloud machine daemon (uncloud.service)...
✓ Uncloud machine daemon started. ✓ Uncloud machine daemon started.
✓ Uncloud installed on the machine successfully! 🎉 ✓ Uncloud installed on the machine successfully! 🎉
@@ -190,20 +190,21 @@ DNS records updated to use only the internet-reachable machines running caddy se
*.xuw3xd.cluster.uncloud.run A → 152.67.101.197 *.xuw3xd.cluster.uncloud.run A → 152.67.101.197
``` ```
1. The CLI SSHs into the machine and installs Docker and the `uncloudd` machine daemon, managed by systemd. 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.
2. Generates a unique WireGuard key pair, allocates a dedicated subnet `10.210.0.0/24` for the machine and its 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` containers, and configures `uncloudd` accordingly. All subsequent communication happens with `uncloudd`
through its gRPC API over SSH. through its gRPC API over SSH.
3. Configures and starts [corrosion](https://github.com/superfly/corrosion), a CRDT-based distributed SQLite database to 3. Configures and starts `corrosion`, a CRDT-based distributed SQLite database to share cluster state between machines.
share cluster state between machines, as a Docker container managed by `uncloudd`.
4. Creates a Docker bridge network connected to the WireGuard interface. 4. Creates a Docker bridge network connected to the WireGuard interface.
5. This machine becomes an entry point for the newly created cluster. Its address is stored as a connection info in the 5. This machine becomes an entry point for the newly created cluster which is stored in the cluster config under
cluster config at `~/.config/uncloud/config.yaml` on your local machine. `~/.config/uncloud` on your local machine.
**When you add another machine:** **When you add another machine:**
```bash ```bash
$ uc machine add --name hetzner-server root@5.223.45.199 $ 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... ⏳ Running Uncloud install script...
✓ Docker is already installed. ✓ Docker is already installed.
✓ Linux user and group 'uncloud' created. ✓ Linux user and group 'uncloud' created.
@@ -212,10 +213,11 @@ $ uc machine add --name hetzner-server root@5.223.45.199
✓ uncloudd binary installed: /usr/local/bin/uncloudd ✓ uncloudd binary installed: /usr/local/bin/uncloudd
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh ⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall ✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
✓ Systemd unit file created: /etc/systemd/system/uncloud.socket
✓ Systemd unit file created: /etc/systemd/system/uncloud.service ✓ Systemd unit file created: /etc/systemd/system/uncloud.service
Created symlink /etc/systemd/system/sockets.target.wants/uncloud.socket → /etc/systemd/system/uncloud.socket.
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /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)... ⏳ Starting Uncloud machine daemon (uncloud.service)...
✓ Uncloud machine daemon started. ✓ Uncloud machine daemon started.
✓ Uncloud installed on the machine successfully! 🎉 ✓ Uncloud installed on the machine successfully! 🎉
@@ -244,10 +246,9 @@ 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. 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 4. Both machines establish a WireGuard tunnel between each other, allowing Docker containers connected to the bridge
network to communicate directly across machines. network to communicate directly across machines.
5. Configures and starts the `uncloud-corrosion` container on the second machine to sync the cluster state. 5. Configures and starts `corrosion` on the second machine to sync the cluster state.
6. The second machine is added as an alternative entry point in the cluster config. 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 be used by `uc` to connect and run cluster 7. If one of the machines goes offline, the other machine can still serve cluster operations.
operations.
If one more machine is added, the process repeats with a new subnet. The new machine needs to establish a WireGuard 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 connection with only one of the existing machines. Other machines will learn about it through the shared cluster state
@@ -325,9 +326,9 @@ I'm grateful to the following projects that inspired Uncloud's design and implem
* [Tailscale](https://tailscale.com/) — for pioneering the vision of decentralised flat mesh networking with an amazing * [Tailscale](https://tailscale.com/) — for pioneering the vision of decentralised flat mesh networking with an amazing
user experience that feels like magic. user experience that feels like magic.
* [Talos Linux](https://github.com/siderolabs/talos) * [Talos Linux](https://github.com/siderolabs/talos)
and [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/) — for inspiring Uncloud's API routing and [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/) — for the machine API design using
design with [grpc-proxy](https://github.com/siderolabs/grpc-proxy) and for their elegant approach to secure [grpc-proxy](https://github.com/siderolabs/grpc-proxy) and for its elegant approach to secure WireGuard-based overlay
WireGuard-based overlay networking with zero configuration. networking with zero configuration.
* [Docker Swarm Classic](https://github.com/docker-archive/classicswarm) and * [Docker Swarm Classic](https://github.com/docker-archive/classicswarm) and
[Rancher 1.x](http://rancher-com-website-main-elb-elb-1798790864.us-west-2.elb.amazonaws.com/docs/rancher/v1.6/en/) [Rancher 1.x](http://rancher-com-website-main-elb-elb-1798790864.us-west-2.elb.amazonaws.com/docs/rancher/v1.6/en/)
— for showing the power of simplicity and pragmatism in container orchestration and that not every problem needs the — for showing the power of simplicity and pragmatism in container orchestration and that not every problem needs the
-848
View File
@@ -1,848 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.34.2
// protoc v5.27.3
// source: api/pb/caddy_storage.proto
package pb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
emptypb "google.golang.org/protobuf/types/known/emptypb"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type StoreCaddyStorageRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
}
func (x *StoreCaddyStorageRequest) Reset() {
*x = StoreCaddyStorageRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *StoreCaddyStorageRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StoreCaddyStorageRequest) ProtoMessage() {}
func (x *StoreCaddyStorageRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StoreCaddyStorageRequest.ProtoReflect.Descriptor instead.
func (*StoreCaddyStorageRequest) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{0}
}
func (x *StoreCaddyStorageRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *StoreCaddyStorageRequest) GetValue() []byte {
if x != nil {
return x.Value
}
return nil
}
type LoadCaddyStorageRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
}
func (x *LoadCaddyStorageRequest) Reset() {
*x = LoadCaddyStorageRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *LoadCaddyStorageRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoadCaddyStorageRequest) ProtoMessage() {}
func (x *LoadCaddyStorageRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_proto_msgTypes[1]
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 LoadCaddyStorageRequest.ProtoReflect.Descriptor instead.
func (*LoadCaddyStorageRequest) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{1}
}
func (x *LoadCaddyStorageRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
type LoadCaddyStorageResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Value []byte `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
}
func (x *LoadCaddyStorageResponse) Reset() {
*x = LoadCaddyStorageResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *LoadCaddyStorageResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoadCaddyStorageResponse) ProtoMessage() {}
func (x *LoadCaddyStorageResponse) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LoadCaddyStorageResponse.ProtoReflect.Descriptor instead.
func (*LoadCaddyStorageResponse) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{2}
}
func (x *LoadCaddyStorageResponse) GetValue() []byte {
if x != nil {
return x.Value
}
return nil
}
func (x *LoadCaddyStorageResponse) GetUpdatedAt() *timestamppb.Timestamp {
if x != nil {
return x.UpdatedAt
}
return nil
}
type DeleteCaddyStorageRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
}
func (x *DeleteCaddyStorageRequest) Reset() {
*x = DeleteCaddyStorageRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *DeleteCaddyStorageRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteCaddyStorageRequest) ProtoMessage() {}
func (x *DeleteCaddyStorageRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_proto_msgTypes[3]
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 DeleteCaddyStorageRequest.ProtoReflect.Descriptor instead.
func (*DeleteCaddyStorageRequest) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{3}
}
func (x *DeleteCaddyStorageRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
type ListCaddyStorageRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"`
Recursive bool `protobuf:"varint,2,opt,name=recursive,proto3" json:"recursive,omitempty"`
}
func (x *ListCaddyStorageRequest) Reset() {
*x = ListCaddyStorageRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListCaddyStorageRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListCaddyStorageRequest) ProtoMessage() {}
func (x *ListCaddyStorageRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_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 ListCaddyStorageRequest.ProtoReflect.Descriptor instead.
func (*ListCaddyStorageRequest) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{4}
}
func (x *ListCaddyStorageRequest) GetPrefix() string {
if x != nil {
return x.Prefix
}
return ""
}
func (x *ListCaddyStorageRequest) GetRecursive() bool {
if x != nil {
return x.Recursive
}
return false
}
type ListCaddyStorageResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Keys []string `protobuf:"bytes,1,rep,name=keys,proto3" json:"keys,omitempty"`
}
func (x *ListCaddyStorageResponse) Reset() {
*x = ListCaddyStorageResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListCaddyStorageResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListCaddyStorageResponse) ProtoMessage() {}
func (x *ListCaddyStorageResponse) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_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 ListCaddyStorageResponse.ProtoReflect.Descriptor instead.
func (*ListCaddyStorageResponse) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{5}
}
func (x *ListCaddyStorageResponse) GetKeys() []string {
if x != nil {
return x.Keys
}
return nil
}
type StatCaddyStorageRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
}
func (x *StatCaddyStorageRequest) Reset() {
*x = StatCaddyStorageRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *StatCaddyStorageRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StatCaddyStorageRequest) ProtoMessage() {}
func (x *StatCaddyStorageRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_proto_msgTypes[6]
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 StatCaddyStorageRequest.ProtoReflect.Descriptor instead.
func (*StatCaddyStorageRequest) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{6}
}
func (x *StatCaddyStorageRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
type StatCaddyStorageResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"`
IsTerminal bool `protobuf:"varint,4,opt,name=is_terminal,json=isTerminal,proto3" json:"is_terminal,omitempty"`
}
func (x *StatCaddyStorageResponse) Reset() {
*x = StatCaddyStorageResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *StatCaddyStorageResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StatCaddyStorageResponse) ProtoMessage() {}
func (x *StatCaddyStorageResponse) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_proto_msgTypes[7]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StatCaddyStorageResponse.ProtoReflect.Descriptor instead.
func (*StatCaddyStorageResponse) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{7}
}
func (x *StatCaddyStorageResponse) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *StatCaddyStorageResponse) GetUpdatedAt() *timestamppb.Timestamp {
if x != nil {
return x.UpdatedAt
}
return nil
}
func (x *StatCaddyStorageResponse) GetSize() int64 {
if x != nil {
return x.Size
}
return 0
}
func (x *StatCaddyStorageResponse) GetIsTerminal() bool {
if x != nil {
return x.IsTerminal
}
return false
}
type ListCertificatesResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Certificates []*IssuedCertificate `protobuf:"bytes,1,rep,name=certificates,proto3" json:"certificates,omitempty"`
}
func (x *ListCertificatesResponse) Reset() {
*x = ListCertificatesResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListCertificatesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListCertificatesResponse) ProtoMessage() {}
func (x *ListCertificatesResponse) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_proto_msgTypes[8]
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 ListCertificatesResponse.ProtoReflect.Descriptor instead.
func (*ListCertificatesResponse) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{8}
}
func (x *ListCertificatesResponse) GetCertificates() []*IssuedCertificate {
if x != nil {
return x.Certificates
}
return nil
}
type IssuedCertificate struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Subject Alternative Name (SAN) of the certificate. Caddy doesn't issue certificates with multiple SANs.
San string `protobuf:"bytes,1,opt,name=san,proto3" json:"san,omitempty"`
// The PEM-encoding of DER-encoded ASN.1 data for the cert or chain, leaf first.
Chain []byte `protobuf:"bytes,2,opt,name=chain,proto3" json:"chain,omitempty"`
// Any extra information associated with the certificate, usually provided by the issuer implementation.
// JSON-encoded, may be absent or null.
IssuerData []byte `protobuf:"bytes,3,opt,name=issuer_data,json=issuerData,proto3" json:"issuer_data,omitempty"`
}
func (x *IssuedCertificate) Reset() {
*x = IssuedCertificate{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_caddy_storage_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *IssuedCertificate) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IssuedCertificate) ProtoMessage() {}
func (x *IssuedCertificate) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_caddy_storage_proto_msgTypes[9]
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 IssuedCertificate.ProtoReflect.Descriptor instead.
func (*IssuedCertificate) Descriptor() ([]byte, []int) {
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{9}
}
func (x *IssuedCertificate) GetSan() string {
if x != nil {
return x.San
}
return ""
}
func (x *IssuedCertificate) GetChain() []byte {
if x != nil {
return x.Chain
}
return nil
}
func (x *IssuedCertificate) GetIssuerData() []byte {
if x != nil {
return x.IssuerData
}
return nil
}
var File_api_pb_caddy_storage_proto protoreflect.FileDescriptor
var file_api_pb_caddy_storage_proto_rawDesc = []byte{
0x0a, 0x1a, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x2f, 0x63, 0x61, 0x64, 0x64, 0x79, 0x5f, 0x73,
0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70,
0x69, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 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,
0x42, 0x0a, 0x18, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f,
0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b,
0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a,
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61,
0x6c, 0x75, 0x65, 0x22, 0x2b, 0x0a, 0x17, 0x4c, 0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, 0x64, 0x79,
0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10,
0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
0x22, 0x6b, 0x0a, 0x18, 0x4c, 0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f,
0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05,
0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c,
0x75, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74,
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, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, 0x2d, 0x0a,
0x19, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72,
0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x4f, 0x0a, 0x17,
0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69,
0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12,
0x1c, 0x0a, 0x09, 0x72, 0x65, 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, 0x65, 0x22, 0x2e, 0x0a,
0x18, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67,
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x65, 0x79,
0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x22, 0x2b, 0x0a,
0x17, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67,
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x9c, 0x01, 0x0a, 0x18, 0x53,
0x74, 0x61, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x39, 0x0a, 0x0a, 0x75, 0x70, 0x64,
0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 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, 0x75, 0x70, 0x64, 0x61, 0x74,
0x65, 0x64, 0x41, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01,
0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x73, 0x5f, 0x74,
0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x69,
0x73, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x22, 0x56, 0x0a, 0x18, 0x4c, 0x69, 0x73,
0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3a, 0x0a, 0x0c, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69,
0x63, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63,
0x61, 0x74, 0x65, 0x52, 0x0c, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65,
0x73, 0x22, 0x5c, 0x0a, 0x11, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69,
0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x61, 0x6e, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x61, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x68, 0x61, 0x69,
0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x12, 0x1f,
0x0a, 0x0b, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20,
0x01, 0x28, 0x0c, 0x52, 0x0a, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x32,
0xaa, 0x03, 0x0a, 0x0c, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65,
0x12, 0x3e, 0x0a, 0x05, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x53, 0x74, 0x6f, 0x72, 0x65, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67,
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, 0x43, 0x0a, 0x04, 0x4c, 0x6f, 0x61, 0x64, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c,
0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x61,
0x64, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12,
0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x61, 0x64, 0x64,
0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 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, 0x43, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12,
0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53,
0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f,
0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x04,
0x53, 0x74, 0x61, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43,
0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64,
0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x49, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69,
0x63, 0x61, 0x74, 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, 0x1d, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63,
0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x26, 0x5a, 0x24,
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, 0x61, 0x70,
0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_api_pb_caddy_storage_proto_rawDescOnce sync.Once
file_api_pb_caddy_storage_proto_rawDescData = file_api_pb_caddy_storage_proto_rawDesc
)
func file_api_pb_caddy_storage_proto_rawDescGZIP() []byte {
file_api_pb_caddy_storage_proto_rawDescOnce.Do(func() {
file_api_pb_caddy_storage_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_pb_caddy_storage_proto_rawDescData)
})
return file_api_pb_caddy_storage_proto_rawDescData
}
var file_api_pb_caddy_storage_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_api_pb_caddy_storage_proto_goTypes = []any{
(*StoreCaddyStorageRequest)(nil), // 0: api.StoreCaddyStorageRequest
(*LoadCaddyStorageRequest)(nil), // 1: api.LoadCaddyStorageRequest
(*LoadCaddyStorageResponse)(nil), // 2: api.LoadCaddyStorageResponse
(*DeleteCaddyStorageRequest)(nil), // 3: api.DeleteCaddyStorageRequest
(*ListCaddyStorageRequest)(nil), // 4: api.ListCaddyStorageRequest
(*ListCaddyStorageResponse)(nil), // 5: api.ListCaddyStorageResponse
(*StatCaddyStorageRequest)(nil), // 6: api.StatCaddyStorageRequest
(*StatCaddyStorageResponse)(nil), // 7: api.StatCaddyStorageResponse
(*ListCertificatesResponse)(nil), // 8: api.ListCertificatesResponse
(*IssuedCertificate)(nil), // 9: api.IssuedCertificate
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
(*emptypb.Empty)(nil), // 11: google.protobuf.Empty
}
var file_api_pb_caddy_storage_proto_depIdxs = []int32{
10, // 0: api.LoadCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp
10, // 1: api.StatCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp
9, // 2: api.ListCertificatesResponse.certificates:type_name -> api.IssuedCertificate
0, // 3: api.CaddyStorage.Store:input_type -> api.StoreCaddyStorageRequest
1, // 4: api.CaddyStorage.Load:input_type -> api.LoadCaddyStorageRequest
3, // 5: api.CaddyStorage.Delete:input_type -> api.DeleteCaddyStorageRequest
4, // 6: api.CaddyStorage.List:input_type -> api.ListCaddyStorageRequest
6, // 7: api.CaddyStorage.Stat:input_type -> api.StatCaddyStorageRequest
11, // 8: api.CaddyStorage.ListCertificates:input_type -> google.protobuf.Empty
11, // 9: api.CaddyStorage.Store:output_type -> google.protobuf.Empty
2, // 10: api.CaddyStorage.Load:output_type -> api.LoadCaddyStorageResponse
11, // 11: api.CaddyStorage.Delete:output_type -> google.protobuf.Empty
5, // 12: api.CaddyStorage.List:output_type -> api.ListCaddyStorageResponse
7, // 13: api.CaddyStorage.Stat:output_type -> api.StatCaddyStorageResponse
8, // 14: api.CaddyStorage.ListCertificates:output_type -> api.ListCertificatesResponse
9, // [9:15] is the sub-list for method output_type
3, // [3:9] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_api_pb_caddy_storage_proto_init() }
func file_api_pb_caddy_storage_proto_init() {
if File_api_pb_caddy_storage_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_api_pb_caddy_storage_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*StoreCaddyStorageRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_caddy_storage_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*LoadCaddyStorageRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_caddy_storage_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*LoadCaddyStorageResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_caddy_storage_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*DeleteCaddyStorageRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_caddy_storage_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*ListCaddyStorageRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_caddy_storage_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*ListCaddyStorageResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_caddy_storage_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*StatCaddyStorageRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_caddy_storage_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*StatCaddyStorageResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_caddy_storage_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*ListCertificatesResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_caddy_storage_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*IssuedCertificate); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_api_pb_caddy_storage_proto_rawDesc,
NumEnums: 0,
NumMessages: 10,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_api_pb_caddy_storage_proto_goTypes,
DependencyIndexes: file_api_pb_caddy_storage_proto_depIdxs,
MessageInfos: file_api_pb_caddy_storage_proto_msgTypes,
}.Build()
File_api_pb_caddy_storage_proto = out.File
file_api_pb_caddy_storage_proto_rawDesc = nil
file_api_pb_caddy_storage_proto_goTypes = nil
file_api_pb_caddy_storage_proto_depIdxs = nil
}
-77
View File
@@ -1,77 +0,0 @@
syntax = "proto3";
package api;
option go_package = "github.com/psviderski/uncloud/api/pb";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
// CaddyStorage exposes the CertMagic storage operations backed by the distributed cluster store.
// Each request operates on the receiving machine's store replica. Callers that need replication to catch up before
// reading should capture store versions with Machine.InspectMachine and call Machine.WaitForStoreVersion on the
// receiving machine, subject to that RPC's data-availability limitations (see WaitForStoreVersion docs).
// See Storage interface in https://github.com/caddyserver/certmagic/blob/master/storage.go.
service CaddyStorage {
rpc Store(StoreCaddyStorageRequest) returns (google.protobuf.Empty);
rpc Load(LoadCaddyStorageRequest) returns (LoadCaddyStorageResponse);
rpc Delete(DeleteCaddyStorageRequest) returns (google.protobuf.Empty);
rpc List(ListCaddyStorageRequest) returns (ListCaddyStorageResponse);
rpc Stat(StatCaddyStorageRequest) returns (StatCaddyStorageResponse);
// ListCertificates lists certificates issued by Caddy and stored in the cluster store.
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
rpc ListCertificates(google.protobuf.Empty) returns (ListCertificatesResponse);
}
message StoreCaddyStorageRequest {
string key = 1;
bytes value = 2;
}
message LoadCaddyStorageRequest {
string key = 1;
}
message LoadCaddyStorageResponse {
bytes value = 1;
google.protobuf.Timestamp updated_at = 2;
}
message DeleteCaddyStorageRequest {
string key = 1;
}
message ListCaddyStorageRequest {
string prefix = 1;
bool recursive = 2;
}
message ListCaddyStorageResponse {
repeated string keys = 1;
}
message StatCaddyStorageRequest {
string key = 1;
}
message StatCaddyStorageResponse {
string key = 1;
google.protobuf.Timestamp updated_at = 2;
int64 size = 3;
bool is_terminal = 4;
}
message ListCertificatesResponse {
repeated IssuedCertificate certificates = 1;
}
message IssuedCertificate {
// Subject Alternative Name (SAN) of the certificate. Caddy doesn't issue certificates with multiple SANs.
string san = 1;
// The PEM-encoding of DER-encoded ASN.1 data for the cert or chain, leaf first.
bytes chain = 2;
// Any extra information associated with the certificate, usually provided by the issuer implementation.
// JSON-encoded, may be absent or null.
bytes issuer_data = 3;
}
-328
View File
@@ -1,328 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// source: api/pb/caddy_storage.proto
package pb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
CaddyStorage_Store_FullMethodName = "/api.CaddyStorage/Store"
CaddyStorage_Load_FullMethodName = "/api.CaddyStorage/Load"
CaddyStorage_Delete_FullMethodName = "/api.CaddyStorage/Delete"
CaddyStorage_List_FullMethodName = "/api.CaddyStorage/List"
CaddyStorage_Stat_FullMethodName = "/api.CaddyStorage/Stat"
CaddyStorage_ListCertificates_FullMethodName = "/api.CaddyStorage/ListCertificates"
)
// CaddyStorageClient is the client API for CaddyStorage service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// CaddyStorage exposes the CertMagic storage operations backed by the distributed cluster store.
// Each request operates on the receiving machine's store replica. Callers that need replication to catch up before
// reading should capture store versions with Machine.InspectMachine and call Machine.WaitForStoreVersion on the
// receiving machine, subject to that RPC's data-availability limitations (see WaitForStoreVersion docs).
// See Storage interface in https://github.com/caddyserver/certmagic/blob/master/storage.go.
type CaddyStorageClient interface {
Store(ctx context.Context, in *StoreCaddyStorageRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
Load(ctx context.Context, in *LoadCaddyStorageRequest, opts ...grpc.CallOption) (*LoadCaddyStorageResponse, error)
Delete(ctx context.Context, in *DeleteCaddyStorageRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
List(ctx context.Context, in *ListCaddyStorageRequest, opts ...grpc.CallOption) (*ListCaddyStorageResponse, error)
Stat(ctx context.Context, in *StatCaddyStorageRequest, opts ...grpc.CallOption) (*StatCaddyStorageResponse, error)
// ListCertificates lists certificates issued by Caddy and stored in the cluster store.
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
ListCertificates(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListCertificatesResponse, error)
}
type caddyStorageClient struct {
cc grpc.ClientConnInterface
}
func NewCaddyStorageClient(cc grpc.ClientConnInterface) CaddyStorageClient {
return &caddyStorageClient{cc}
}
func (c *caddyStorageClient) Store(ctx context.Context, in *StoreCaddyStorageRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, CaddyStorage_Store_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *caddyStorageClient) Load(ctx context.Context, in *LoadCaddyStorageRequest, opts ...grpc.CallOption) (*LoadCaddyStorageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LoadCaddyStorageResponse)
err := c.cc.Invoke(ctx, CaddyStorage_Load_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *caddyStorageClient) Delete(ctx context.Context, in *DeleteCaddyStorageRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, CaddyStorage_Delete_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *caddyStorageClient) List(ctx context.Context, in *ListCaddyStorageRequest, opts ...grpc.CallOption) (*ListCaddyStorageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListCaddyStorageResponse)
err := c.cc.Invoke(ctx, CaddyStorage_List_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *caddyStorageClient) Stat(ctx context.Context, in *StatCaddyStorageRequest, opts ...grpc.CallOption) (*StatCaddyStorageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StatCaddyStorageResponse)
err := c.cc.Invoke(ctx, CaddyStorage_Stat_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *caddyStorageClient) ListCertificates(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListCertificatesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListCertificatesResponse)
err := c.cc.Invoke(ctx, CaddyStorage_ListCertificates_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// CaddyStorageServer is the server API for CaddyStorage service.
// All implementations must embed UnimplementedCaddyStorageServer
// for forward compatibility.
//
// CaddyStorage exposes the CertMagic storage operations backed by the distributed cluster store.
// Each request operates on the receiving machine's store replica. Callers that need replication to catch up before
// reading should capture store versions with Machine.InspectMachine and call Machine.WaitForStoreVersion on the
// receiving machine, subject to that RPC's data-availability limitations (see WaitForStoreVersion docs).
// See Storage interface in https://github.com/caddyserver/certmagic/blob/master/storage.go.
type CaddyStorageServer interface {
Store(context.Context, *StoreCaddyStorageRequest) (*emptypb.Empty, error)
Load(context.Context, *LoadCaddyStorageRequest) (*LoadCaddyStorageResponse, error)
Delete(context.Context, *DeleteCaddyStorageRequest) (*emptypb.Empty, error)
List(context.Context, *ListCaddyStorageRequest) (*ListCaddyStorageResponse, error)
Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error)
// ListCertificates lists certificates issued by Caddy and stored in the cluster store.
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
ListCertificates(context.Context, *emptypb.Empty) (*ListCertificatesResponse, error)
mustEmbedUnimplementedCaddyStorageServer()
}
// UnimplementedCaddyStorageServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedCaddyStorageServer struct{}
func (UnimplementedCaddyStorageServer) Store(context.Context, *StoreCaddyStorageRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method Store not implemented")
}
func (UnimplementedCaddyStorageServer) Load(context.Context, *LoadCaddyStorageRequest) (*LoadCaddyStorageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Load not implemented")
}
func (UnimplementedCaddyStorageServer) Delete(context.Context, *DeleteCaddyStorageRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method Delete not implemented")
}
func (UnimplementedCaddyStorageServer) List(context.Context, *ListCaddyStorageRequest) (*ListCaddyStorageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method List not implemented")
}
func (UnimplementedCaddyStorageServer) Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Stat not implemented")
}
func (UnimplementedCaddyStorageServer) ListCertificates(context.Context, *emptypb.Empty) (*ListCertificatesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCertificates not implemented")
}
func (UnimplementedCaddyStorageServer) mustEmbedUnimplementedCaddyStorageServer() {}
func (UnimplementedCaddyStorageServer) testEmbeddedByValue() {}
// UnsafeCaddyStorageServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to CaddyStorageServer will
// result in compilation errors.
type UnsafeCaddyStorageServer interface {
mustEmbedUnimplementedCaddyStorageServer()
}
func RegisterCaddyStorageServer(s grpc.ServiceRegistrar, srv CaddyStorageServer) {
// If the following call pancis, it indicates UnimplementedCaddyStorageServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&CaddyStorage_ServiceDesc, srv)
}
func _CaddyStorage_Store_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StoreCaddyStorageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CaddyStorageServer).Store(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CaddyStorage_Store_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CaddyStorageServer).Store(ctx, req.(*StoreCaddyStorageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CaddyStorage_Load_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LoadCaddyStorageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CaddyStorageServer).Load(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CaddyStorage_Load_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CaddyStorageServer).Load(ctx, req.(*LoadCaddyStorageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CaddyStorage_Delete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteCaddyStorageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CaddyStorageServer).Delete(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CaddyStorage_Delete_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CaddyStorageServer).Delete(ctx, req.(*DeleteCaddyStorageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CaddyStorage_List_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListCaddyStorageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CaddyStorageServer).List(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CaddyStorage_List_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CaddyStorageServer).List(ctx, req.(*ListCaddyStorageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CaddyStorage_Stat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StatCaddyStorageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CaddyStorageServer).Stat(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CaddyStorage_Stat_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CaddyStorageServer).Stat(ctx, req.(*StatCaddyStorageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CaddyStorage_ListCertificates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(emptypb.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CaddyStorageServer).ListCertificates(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CaddyStorage_ListCertificates_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CaddyStorageServer).ListCertificates(ctx, req.(*emptypb.Empty))
}
return interceptor(ctx, in, info, handler)
}
// CaddyStorage_ServiceDesc is the grpc.ServiceDesc for CaddyStorage service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var CaddyStorage_ServiceDesc = grpc.ServiceDesc{
ServiceName: "api.CaddyStorage",
HandlerType: (*CaddyStorageServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Store",
Handler: _CaddyStorage_Store_Handler,
},
{
MethodName: "Load",
Handler: _CaddyStorage_Load_Handler,
},
{
MethodName: "Delete",
Handler: _CaddyStorage_Delete_Handler,
},
{
MethodName: "List",
Handler: _CaddyStorage_List_Handler,
},
{
MethodName: "Stat",
Handler: _CaddyStorage_Stat_Handler,
},
{
MethodName: "ListCertificates",
Handler: _CaddyStorage_ListCertificates_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "api/pb/caddy_storage.proto",
}
-1986
View File
File diff suppressed because it is too large Load Diff
-206
View File
@@ -1,206 +0,0 @@
syntax = "proto3";
package api;
option go_package = "github.com/psviderski/uncloud/api/pb";
import "google/protobuf/duration.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
import "api/pb/common.proto";
service Machine {
// CheckPrerequisites verifies if the machine meets all necessary system requirements to participate in the cluster.
rpc CheckPrerequisites(google.protobuf.Empty) returns (CheckPrerequisitesResponse);
rpc InitCluster(InitClusterRequest) returns (InitClusterResponse);
rpc JoinCluster(JoinClusterRequest) returns (google.protobuf.Empty);
rpc Token(google.protobuf.Empty) returns (TokenResponse);
// Deprecated: use InspectMachine instead.
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);
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
// no known missing or pending transactions through those versions from active members.
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
// have been superseded.
//
// Waiting normally makes the captured data available on this machine. However, another write may replace some of that
// data before it arrives. Corrosion can then complete the older version without transferring the replaced data.
// If the replacement is outside the requested versions, this RPC can succeed while the affected data is still missing
// or outdated. This can happen during concurrent updates even when all machines are well connected.
//
// Success does not guarantee an exact snapshot or delivery of every historical value. Callers that require a specific
// record or condition should verify it after waiting.
//
// This RPC observes native replication without initiating synchronisation.
// Use the RPC deadline to bound the wait.
rpc WaitForStoreVersion(WaitForStoreVersionRequest) returns (google.protobuf.Empty);
// 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.
rpc Reset(ResetRequest) returns (google.protobuf.Empty);
rpc InspectService(InspectServiceRequest) returns (InspectServiceResponse);
rpc MachineLogs(LogsRequest) returns (stream LogEntry);
}
message MachineInfo {
string id = 1;
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 {
IPPrefix subnet = 1;
IP management_ip = 2;
repeated IPPort endpoints = 3;
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;
// Error message if not satisfied (empty if all checks pass).
string error = 2;
}
message InitClusterRequest {
string machineName = 1;
IPPrefix network = 2;
oneof public_ip_config {
IP public_ip = 3;
bool public_ip_auto = 4;
}
// 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 {
MachineInfo machine = 1;
}
message JoinClusterRequest {
MachineInfo machine = 1;
repeated MachineInfo other_machines = 3;
// 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 → minimum required db_version).
map<string, uint64> min_store_version = 6;
}
message InspectMachineResponse {
// Must contain only one repeated messages field to allow broadcasting InspectMachine requests to multiple machines.
repeated MachineDetails machines = 1;
}
message MachineDetails {
Metadata metadata = 1;
MachineInfo machine = 2;
// Round-trip times to other machines in the cluster, keyed by peer machine ID.
map<string, RTTStats> rtts = 4;
// Store replication progress observed on this machine.
// Maps each Corrosion actor UUID to its highest processed database version. Corrosion also counts versions whose
// changes were superseded and skipped. Versions at or below a reported version may still be missing or pending
// locally.
//
// Pass this vector to WaitForStoreVersion on another machine to wait for replication through these versions.
// See that RPC's data-availability limitations. To combine observations from several machines, take the maximum
// for each actor.
// Capturing this vector does not wait for replication or prevent further writes.
map<string, uint64> store_version = 5;
}
message WaitForStoreVersionRequest {
// Minimum database version to reach for each Corrosion actor UUID.
// Obtain a vector from InspectMachine. To combine several responses, take the maximum version for each actor.
// Actors omitted from this vector and versions above its targets are not awaited. An empty vector requires
// no replication. Unknown actors with positive targets remain pending until replication reaches them or the RPC ends.
map<string, uint64> min_version = 1;
}
message TokenResponse {
string token = 1;
}
message ResetRequest {
}
message Service {
string id = 1;
string name = 2;
string mode = 3;
message Container {
string machine_id = 1;
// JSON encoded Docker types.Container.
bytes container = 2;
}
repeated Container containers = 4;
}
message InspectServiceRequest {
string id = 1;
}
message InspectServiceResponse {
Service service = 1;
}
message InspectWireGuardNetworkResponse {
string interface_name = 1;
bytes public_key = 2;
int32 listen_port = 3;
repeated WireGuardPeer peers = 4;
}
message WireGuardPeer {
bytes public_key = 1;
string endpoint = 2;
google.protobuf.Timestamp last_handshake_time = 3;
int64 receive_bytes = 4;
int64 transmit_bytes = 5;
repeated string allowed_ips = 6;
}
message RTTStats {
google.protobuf.Duration median = 1;
google.protobuf.Duration std_dev = 2;
}
-4
View File
@@ -1,4 +0,0 @@
_ _ ___
| | | |/ __|
| |_| | (__
\__,_|\___|
-171
View File
@@ -1,171 +0,0 @@
package cert
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"slices"
"strings"
"time"
"charm.land/lipgloss/v2"
"github.com/caddyserver/certmagic"
"github.com/docker/go-units"
"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/version"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra"
)
type listOptions struct {
machine string
output string
}
func NewListCommand() *cobra.Command {
opts := listOptions{}
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List certificates in cluster storage for Caddy.",
Long: `List certificates stored in the Uncloud cluster storage for Caddy.
Caddy uses cluster storage by default. Certificates don't appear here if Caddy uses local storage,
for example, if it runs with a custom global config without 'storage uncloud' or was deployed with
uc v0.21.0 or earlier and not upgraded.
This inventory does not check whether Caddy currently serves or trusts a certificate.
See ` + version.DocsURL + `/concepts/ingress/cluster-storage-for-caddy for details.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to read certificate storage from. (default is connected machine)")
cmd.Flags().StringVarP(&opts.output, "output", "o", "",
"Output format: 'json' or empty for a human-readable table.")
completion.MachinesFlag(cmd)
return cmd
}
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
if opts.output != "" && opts.output != "json" {
return fmt.Errorf("unsupported output format '%s' (supported: json)", opts.output)
}
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
certs, listErr := clusterClient.Caddy.ListCertificates(ctx,
client.CaddyListCertificatesOptions{Machine: opts.machine})
if len(certs) > 0 || listErr == nil {
if err = printCertificates(os.Stdout, certs, opts.output); err != nil {
return fmt.Errorf("print Caddy certificates: %w", err)
}
}
if listErr != nil {
if len(certs) > 0 {
return fmt.Errorf("certificate list is partial: %w", listErr)
}
return fmt.Errorf("list Caddy certificates: %w", listErr)
}
return nil
}
func printCertificates(out io.Writer, certs []api.IssuedCertificate, output string) error {
type certificate struct {
api.IssuedCertificate
Fingerprint string
}
items := make([]certificate, 0, len(certs))
for _, cert := range certs {
fingerprint := cert.Fingerprint()
items = append(items, certificate{
IssuedCertificate: cert,
Fingerprint: hex.EncodeToString(fingerprint[:]),
})
}
slices.SortFunc(items, func(a, b certificate) int {
if cmp := strings.Compare(a.SAN, b.SAN); cmp != 0 {
return cmp
}
if cmp := a.Chain[0].NotAfter.Compare(b.Chain[0].NotAfter); cmp != 0 {
return cmp
}
return strings.Compare(a.Fingerprint, b.Fingerprint)
})
if output == "json" {
encoder := json.NewEncoder(out)
encoder.SetIndent("", " ")
if err := encoder.Encode(items); err != nil {
return fmt.Errorf("marshal certificates: %w", err)
}
return nil
}
if len(items) == 0 {
_, err := fmt.Fprintln(out, "No Caddy certificates found in cluster storage.")
return err
}
t := tui.NewTable()
t.Headers("ID", "NAME", "ISSUER", "EXPIRES")
for _, item := range items {
t.Row(
item.Fingerprint[:12],
item.SAN,
formatIssuer(item.IssuerData),
formatExpiry(item.Chain[0].NotAfter),
)
}
_, err := lipgloss.Fprintln(out, t)
return err
}
func formatIssuer(data api.CertificateIssuerData) string {
if data.ACME == nil {
return "unknown"
}
switch data.ACME.CA {
case certmagic.LetsEncryptProductionCA:
return "Let's Encrypt"
case certmagic.LetsEncryptStagingCA:
return "Let's Encrypt (staging)"
case certmagic.ZeroSSLProductionCA:
return "ZeroSSL"
case certmagic.GoogleTrustProductionCA:
return "Google Trust Services"
case certmagic.GoogleTrustStagingCA:
return "Google Trust Services (staging)"
default:
return data.ACME.CA
}
}
func formatExpiry(expires time.Time) string {
delta := expires.Sub(time.Now())
if delta <= 0 {
return fmt.Sprintf("%s (expired %s ago)", expires.UTC().Format(time.DateOnly),
strings.ToLower(units.HumanDuration(-delta)))
}
return fmt.Sprintf("%s (%s)", expires.UTC().Format(time.DateOnly),
strings.ToLower(units.HumanDuration(delta)))
}
-128
View File
@@ -1,128 +0,0 @@
package cert
import (
"bytes"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/json"
"strings"
"testing"
"time"
"github.com/caddyserver/certmagic"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPrintCertificates(t *testing.T) {
now := time.Now().UTC().Truncate(time.Hour)
certs := []api.IssuedCertificate{
testIssuedCertificate("z.example.com", "z", now.Add(48*time.Hour), certmagic.LetsEncryptProductionCA),
testIssuedCertificate("a.example.com", "old", now.Add(-48*time.Hour), certmagic.LetsEncryptStagingCA),
testIssuedCertificate("a.example.com", "new", now.Add(72*time.Hour), "https://ca.example/directory"),
testIssuedCertificate("unknown.example.com", "unknown", now.Add(96*time.Hour), ""),
}
var table bytes.Buffer
require.NoError(t, printCertificates(&table, certs, ""))
output := table.String()
assert.Contains(t, output, "ID")
assert.Contains(t, output, "NAME")
assert.Contains(t, output, "ISSUER")
assert.Contains(t, output, "EXPIRES")
assert.Contains(t, output, now.Add(-48*time.Hour).Format(time.DateOnly)+" (expired ")
assert.Contains(t, output, now.Add(48*time.Hour).Format(time.DateOnly)+" (")
assert.Contains(t, output, "Let's Encrypt (staging)")
assert.Contains(t, output, "https://ca.example/directory")
assert.Less(t, strings.Index(output, now.Add(-48*time.Hour).Format(time.DateOnly)),
strings.Index(output, now.Add(72*time.Hour).Format(time.DateOnly)))
assert.Less(t, strings.Index(output, "a.example.com"), strings.Index(output, "z.example.com"))
assert.Contains(t, output, testFingerprint("old")[:12])
var encoded bytes.Buffer
require.NoError(t, printCertificates(&encoded, certs, "json"))
var items []struct {
api.IssuedCertificate
Fingerprint string
}
require.NoError(t, json.Unmarshal(encoded.Bytes(), &items))
require.Len(t, items, 4)
assert.Equal(t, testFingerprint("old"), items[0].Fingerprint)
assert.Equal(t, "a.example.com", items[0].SAN)
require.Len(t, items[0].Chain, 1)
assert.Equal(t, []byte("old"), items[0].Chain[0].Raw)
assert.Equal(t, now.Add(-48*time.Hour), items[0].Chain[0].NotAfter)
require.NotNil(t, items[0].IssuerData.ACME)
assert.Equal(t, certmagic.LetsEncryptStagingCA, items[0].IssuerData.ACME.CA)
assert.Equal(t, testFingerprint("new"), items[1].Fingerprint)
require.NotNil(t, items[1].IssuerData.ACME)
assert.Equal(t, "https://ca.example/directory", items[1].IssuerData.ACME.CA)
assert.Nil(t, items[2].IssuerData.ACME)
assert.Equal(t, "z.example.com", items[3].SAN)
}
func TestPrintCertificates_Empty(t *testing.T) {
for _, tt := range []struct {
output string
want string
}{
{output: "", want: "No Caddy certificates found in cluster storage.\n"},
{output: "json", want: "[]\n"},
} {
var out bytes.Buffer
require.NoError(t, printCertificates(&out, nil, tt.output))
assert.Equal(t, tt.want, out.String())
}
}
func TestFormatIssuer(t *testing.T) {
for _, tt := range []struct {
name string
ca string
want string
}{
{name: "production", ca: certmagic.LetsEncryptProductionCA, want: "Let's Encrypt"},
{name: "staging", ca: certmagic.LetsEncryptStagingCA, want: "Let's Encrypt (staging)"},
{name: "ZeroSSL", ca: certmagic.ZeroSSLProductionCA, want: "ZeroSSL"},
{name: "Google Trust Services", ca: certmagic.GoogleTrustProductionCA, want: "Google Trust Services"},
{name: "Google Trust Services staging", ca: certmagic.GoogleTrustStagingCA, want: "Google Trust Services (staging)"},
{name: "other", ca: "https://ca.example/directory", want: "https://ca.example/directory"},
} {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, formatIssuer(api.CertificateIssuerData{
ACME: &api.ACMEIssuerData{CA: tt.ca},
}))
})
}
assert.Equal(t, "unknown", formatIssuer(api.CertificateIssuerData{}))
}
func TestFormatExpiry_UTC(t *testing.T) {
now := time.Now().UTC()
brisbane := time.FixedZone("AEST", 10*60*60)
expires := time.Date(now.Year(), now.Month(), now.Day()+3, 3, 0, 0, 0, brisbane)
assert.True(t, strings.HasPrefix(formatExpiry(expires), expires.UTC().Format(time.DateOnly)+" ("))
assert.NotEqual(t, expires.Format(time.DateOnly), expires.UTC().Format(time.DateOnly))
expired := now.Add(-2 * time.Hour)
assert.True(t, strings.HasPrefix(formatExpiry(expired), expired.UTC().Format(time.DateOnly)+" (expired "))
assert.Contains(t, formatExpiry(expired), " ago)")
}
func testIssuedCertificate(name, raw string, expires time.Time, ca string) api.IssuedCertificate {
cert := api.IssuedCertificate{
SAN: name,
Chain: []*x509.Certificate{{Raw: []byte(raw), NotAfter: expires}},
}
if ca != "" {
cert.IssuerData.ACME = &api.ACMEIssuerData{CA: ca}
}
return cert
}
func testFingerprint(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
-12
View File
@@ -1,12 +0,0 @@
package cert
import "github.com/spf13/cobra"
func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cert",
Short: "Inspect certificates in cluster storage for Caddy.",
}
cmd.AddCommand(NewListCommand())
return cmd
}
-420
View File
@@ -1,420 +0,0 @@
package caddy
import (
"context"
"errors"
"fmt"
"maps"
"os"
"slices"
"strings"
"charm.land/lipgloss/v2"
"github.com/docker/cli/cli/streams"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/api/pb"
"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/version"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra"
)
type deployOptions struct {
caddyfile string
image string
machines []string
caddyfileChanged bool
imageChanged bool
machinesChanged bool
}
func NewDeployCommand() *cobra.Command {
opts := deployOptions{}
cmd := &cobra.Command{
Use: "deploy",
Short: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.",
Long: `Deploy or upgrade Caddy reverse proxy across all machines in the cluster.
A rolling update is performed when updating existing containers to minimise disruption.
By default, this deploys the latest version of the Uncloud Caddy image with a global config that enables cluster
storage for TLS certificates. See https://github.com/unlabs-dev/caddy-uncloud for more details.
Values set with --image, --caddyfile, and --machine are kept for later deployments. Pass an empty value to reset
a flag to its default.
If this command didn't deploy the current caddy service, for example, it was deployed with 'uc deploy'
from a Compose file or an older version of uc, the deployment replaces its configuration.
Requires Uncloud daemon ` + client.MinCaddyDaemonVersion + ` or newer on all machines.`,
Example: ` # Deploy Caddy or upgrade it to the latest version of the Uncloud Caddy image.
uc caddy deploy
# Deploy a custom image and keep it for later deployments.
uc caddy deploy --image ghcr.io/unlabs-dev/caddy-uncloud:0.1.3
# Go back to the latest version of the Uncloud Caddy image.
uc caddy deploy --image ""
# Deploy a custom image build and a custom global Caddy config and keep both for later deployments.
uc caddy deploy --image myorg/caddy-uncloud:latest --caddyfile global.Caddyfile
# Go back to the default global Caddy config but keep the custom image.
uc caddy deploy --caddyfile ""
# Deploy only to specific machines and keep them for later deployments.
uc caddy deploy --machine machine1,machine2
# Go back to deploying to all machines.
uc caddy deploy --machine ""`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.caddyfileChanged = cmd.Flag("caddyfile").Changed
opts.imageChanged = cmd.Flag("image").Changed
opts.machinesChanged = cmd.Flag("machine").Changed
return runDeploy(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
"Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.\n"+
"An empty value resets it to the default config that enables cluster storage.\n"+
"(default is the current custom config or the default config)")
cmd.Flags().StringVar(&opts.image, "image", "",
"Caddy Docker image to deploy. It must include the Uncloud storage module if the Caddy config uses it.\n"+
"An empty value resets it to the latest version of the Uncloud Caddy image.\n"+
"(default is the current custom image or "+client.CaddyImage+":LATEST_VERSION)")
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated list.\n"+
"An empty value resets it to all machines.\n"+
"(default is the current machines or all machines)")
completion.MachinesFlag(cmd)
return cmd
}
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
config := ""
if opts.caddyfile != "" {
data, err := os.ReadFile(opts.caddyfile)
if err != nil {
return fmt.Errorf("read Caddyfile: %w", err)
}
config = strings.TrimSpace(string(data))
}
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
if err = clusterClient.Caddy.CheckDaemonVersions(ctx); err != nil {
return err
}
svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
if err != nil {
if !errors.Is(err, api.ErrNotFound) {
return fmt.Errorf("inspect caddy service: %w", err)
}
fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(client.CaddyServiceName) +
tui.Faint.Render(" (not running)"))
} else {
fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(svc.Name) +
tui.Faint.Render(" ("+svc.Mode+" mode)"))
// Collect unique images of all containers in the running caddy service.
images := make(map[string]struct{}, len(svc.Containers))
for _, c := range svc.Containers {
images[c.Container.Config.Image] = struct{}{}
}
currentImages := slices.Collect(maps.Keys(images))
if len(currentImages) > 1 {
formattedImages := make([]string, len(currentImages))
for i, img := range currentImages {
formattedImages[i] = tui.FormatImage(img, tui.NoStyle)
}
fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") +
strings.Join(formattedImages, tui.Faint.Render(", ")))
} else {
fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], tui.NoStyle))
}
}
current := svc.Spec()
deployOpts := resolveDeploymentOptions(current, opts, config)
configChanged := deployOpts.ConfigOrDefault() != current.CaddyConfig()
if svc.ID != "" && configChanged {
printCaddyConfig("current global Caddy config:", current.CaddyConfig())
}
fmt.Println()
fmt.Println("Preparing a deployment plan...")
d, err := clusterClient.Caddy.NewDeployment(ctx, deployOpts)
if err != nil {
return fmt.Errorf("create caddy deployment: %w", err)
}
target := d.Spec
// The Uncloud Caddy image resolves to the latest version allowed by this CLI, which is older than the running image
// if a newer CLI deployed it. Continuing would downgrade the image and replace its default config.
if deployOpts.Image == "" && client.IsNewerCaddyImage(current.Container.Image, target.Container.Image) {
return fmt.Errorf("caddy service runs image '%s' which is newer than the latest version '%s' supported by "+
"this uc version: upgrade uc or specify the image with --image",
current.Container.Image, target.Container.Image)
}
imageNote := " (latest)"
if deployOpts.Image != "" {
imageNote = " (custom)"
if !opts.imageChanged {
imageNote = " (custom, use --image \"\" for the latest version)"
}
}
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(target.Container.Image, tui.Green) +
tui.Faint.Render(imageNote))
if configChanged {
if deployOpts.Config != nil {
// The user provided this config so there is no need to print its content.
fmt.Println(tui.Faint.Render("target global Caddy config: ") + opts.caddyfile)
} else {
printCaddyConfig("target global Caddy config: (default)", deployOpts.ConfigOrDefault())
}
}
plan, err := d.Plan(ctx)
if err != nil {
return fmt.Errorf("plan caddy deployment: %w", err)
}
if len(plan.Operations) == 0 {
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
} else {
if svc.ID == "" {
if len(deployOpts.Placement.Machines) > 0 {
fmt.Println("This will run a Caddy container on each selected machine.")
} else {
fmt.Println("This will run a Caddy container on each machine.")
}
}
fmt.Println()
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
fmt.Println()
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
deployTarget := ""
if directConn != "" {
deployTarget = directConn
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
fmt.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
deployTarget = contextName
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
fmt.Println()
}
fmt.Println(plan.Format())
summary := plan.FormatSummary()
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
fmt.Println()
warnings := deploymentWarnings(current, target, opts)
for _, warning := range warnings {
tui.PrintWarning(warning)
}
if len(warnings) > 0 {
fmt.Println()
}
title := "Proceed with deployment?"
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
// deploying to the wrong cluster.
if deployTarget != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with deployment to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
}
confirmed, err := tui.Confirm(title)
if err != nil {
return fmt.Errorf("confirm deployment: %w", err)
}
if !confirmed {
return cli.Cancelled("Caddy deploy cancelled. No changes were made.")
}
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if _, err = d.Run(ctx); err != nil {
return fmt.Errorf("deploy caddy: %w", err)
}
return nil
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode))
if err != nil {
return err
}
}
fmt.Println()
return UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
}
// resolveDeploymentOptions returns the deployment options for the flags and the current service spec, which is zero if
// the service doesn't exist. Each flag works on its own: a specified flag overrides the value and an empty one resets
// it to the default. A flag that isn't specified keeps the explicitly set value.
func resolveDeploymentOptions(
current api.ServiceSpec, opts deployOptions, config string,
) client.CaddyDeploymentOptions {
var resolved client.CaddyDeploymentOptions
if opts.imageChanged {
resolved.Image = opts.image
} else if _, custom := current.Labels[api.LabelCaddyImageCustom]; custom {
resolved.Image = current.Container.Image
}
if opts.caddyfileChanged {
if opts.caddyfile != "" {
resolved.Config = new(config)
}
} else if _, custom := current.Labels[api.LabelCaddyConfigCustom]; custom {
resolved.Config = new(current.CaddyConfig())
}
if opts.machinesChanged {
resolved.Placement.Machines = cli.ExpandCommaSeparatedValues(opts.machines)
} else if _, managed := current.Labels[api.LabelCaddyManaged]; managed {
resolved.Placement = current.Placement
}
return resolved
}
// deploymentWarnings returns the consequences of deploying the target spec over the current one, which is zero if
// the service doesn't exist.
func deploymentWarnings(current, target api.ServiceSpec, opts deployOptions) []string {
var warnings []string
exists := current.Name != ""
if _, managed := current.Labels[api.LabelCaddyManaged]; exists && !managed {
warnings = append(warnings, "'uc caddy deploy' doesn't manage this caddy service yet, so this deployment "+
"replaces its configuration, including any custom global Caddy config.\n"+
"If you manage it with a Compose file, cancel and deploy with 'uc deploy' instead.")
}
currentStorage := client.CaddyStorageModule(current.CaddyConfig())
targetStorage := client.CaddyStorageModule(target.CaddyConfig())
if exists && currentStorage != targetStorage {
warning := fmt.Sprintf("Caddy certificate storage changes from %s to %s.\n"+
"Caddy doesn't automatically migrate certificates, so all of them will be reissued and HTTPS may fail "+
"for those domains until that finishes.\n"+
"To migrate them first, see %s/concepts/ingress/cluster-storage-for-caddy",
caddyStorageName(currentStorage), caddyStorageName(targetStorage), version.DocsURL)
if !opts.caddyfileChanged {
warning += "\nTo keep the current storage, specify a custom Caddyfile that uses it with --caddyfile."
}
warnings = append(warnings, warning)
}
imageOrConfigChanged := current.Container.Image != target.Container.Image ||
current.CaddyConfig() != target.CaddyConfig()
if targetStorage == client.CaddyStorageUncloud && imageOrConfigChanged &&
!client.IsDefaultCaddyImage(target.Container.Image) {
warnings = append(warnings, fmt.Sprintf("Image '%s' must include the Uncloud storage module to use "+
"'storage uncloud'. Otherwise, Caddy won't start or apply the new config.\n"+
"Check 'uc caddy config' after the deployment for errors.", target.Container.Image))
}
return warnings
}
// caddyStorageName returns a user-friendly name of the Caddy storage module.
func caddyStorageName(module string) string {
switch module {
case client.CaddyStorageFileSystem:
return "local"
case client.CaddyStorageUncloud:
return "cluster"
default:
return "'" + module + "'"
}
}
// printCaddyConfig prints the title and the indented config or "none" if it's empty.
func printCaddyConfig(title, config string) {
if config == "" {
lipgloss.Println(tui.Faint.Render(title) + " none")
return
}
lipgloss.Println(tui.Faint.Render(title))
fmt.Println(config)
}
func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
domain, err := clusterClient.GetDomain(ctx)
if err != nil {
if errors.Is(err, api.ErrNotFound) {
fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').")
return nil
}
return fmt.Errorf("get cluster domain: %w", err)
}
if !domain.Reserved {
fmt.Printf("Skipping DNS records update as cluster domain '%s' is managed externally.\n", domain.Name)
return nil
}
fmt.Println("Updating cluster domain records in Uncloud DNS to point to machines running caddy service...")
// TODO: split the method into two: one to get the records and one to update them to ask for update confirmation.
var records []*pb.DNSRecord
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
var err error
records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName)
return err
}, progressOut, "Verifying internet access to caddy service")
if err != nil {
if errors.Is(err, client.ErrNoReachableMachines) {
fmt.Println()
fmt.Printf("DNS records for domain '%s' could not be updated as there are no internet-reachable "+
"machines running caddy containers.\n", domain.Name)
fmt.Println()
fmt.Println("Possible solutions:")
fmt.Println("- Ensure your machines have public IP addresses")
fmt.Println("- Use --public-ip flag when adding machines to override the automatically detected IPs")
fmt.Println("- Check firewall settings on your machines")
fmt.Println("- Configure port forwarding if behind NAT")
fmt.Println("- Retry Caddy deployment with 'uc caddy deploy' after resolving connectivity issues")
fmt.Println()
fmt.Println("Your services won't be accessible from the internet until at least one machine " +
"becomes reachable. If you aren't planning to expose any services publicly, you can release " +
"the domain by running 'uc dns release'.")
}
return fmt.Errorf("failed to update DNS records pointing to caddy service: %w", err)
}
fmt.Println()
fmt.Println("DNS records updated to use only the internet-reachable machines running caddy service:")
for _, r := range records {
fmt.Printf(" %s %s → %s\n", r.Name, r.Type, strings.Join(r.Values, ", "))
}
return nil
}
-263
View File
@@ -1,263 +0,0 @@
package caddy
import (
"strings"
"testing"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/stretchr/testify/assert"
)
func TestResolveDeploymentOptions(t *testing.T) {
t.Parallel()
const (
customImage = "example.com/caddy:1.2.3"
customConfig = "{\n\tdebug\n}"
)
machines := api.Placement{Machines: []string{"m1", "m2"}}
// A service with a custom image, config, and machines that has the given labels.
service := func(labels ...string) api.ServiceSpec {
spec := api.ServiceSpec{
Name: client.CaddyServiceName,
Caddy: &api.CaddySpec{Config: customConfig},
Container: api.ContainerSpec{Image: customImage},
Placement: machines,
}
if len(labels) > 0 {
spec.Labels = make(map[string]string, len(labels))
for _, label := range labels {
spec.Labels[label] = ""
}
}
return spec
}
tests := []struct {
name string
current api.ServiceSpec
opts deployOptions
config string
want client.CaddyDeploymentOptions
}{
{
name: "no service gets the defaults",
},
{
name: "service that isn't managed gets the defaults",
current: service(),
},
{
name: "managed service follows the default image and config and keeps machines",
current: service(api.LabelCaddyManaged),
want: client.CaddyDeploymentOptions{Placement: machines},
},
{
name: "custom image is kept and config follows the default",
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom),
want: client.CaddyDeploymentOptions{Image: customImage, Placement: machines},
},
{
name: "custom config is kept and image follows the latest version",
current: service(api.LabelCaddyManaged, api.LabelCaddyConfigCustom),
want: client.CaddyDeploymentOptions{Config: new(customConfig), Placement: machines},
},
{
name: "custom image and config are kept",
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom, api.LabelCaddyConfigCustom),
want: client.CaddyDeploymentOptions{Image: customImage, Config: new(customConfig), Placement: machines},
},
{
name: "custom empty config is kept",
current: api.ServiceSpec{
Name: client.CaddyServiceName,
Labels: map[string]string{api.LabelCaddyManaged: "", api.LabelCaddyConfigCustom: ""},
},
want: client.CaddyDeploymentOptions{Config: new("")},
},
{
name: "image flag overrides the current image",
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom),
opts: deployOptions{image: "caddy:2", imageChanged: true},
want: client.CaddyDeploymentOptions{Image: "caddy:2", Placement: machines},
},
{
name: "empty image flag resets to the latest version",
current: service(api.LabelCaddyManaged, api.LabelCaddyImageCustom),
opts: deployOptions{imageChanged: true},
want: client.CaddyDeploymentOptions{Placement: machines},
},
{
name: "caddyfile flag overrides the current config",
current: service(api.LabelCaddyManaged),
opts: deployOptions{caddyfile: "global.Caddyfile", caddyfileChanged: true},
config: "{\n\tstorage redis\n}",
want: client.CaddyDeploymentOptions{Config: new("{\n\tstorage redis\n}"), Placement: machines},
},
{
name: "caddyfile flag with an empty file sets a custom empty config",
current: service(api.LabelCaddyManaged),
opts: deployOptions{caddyfile: "empty.Caddyfile", caddyfileChanged: true},
want: client.CaddyDeploymentOptions{Config: new(""), Placement: machines},
},
{
name: "empty caddyfile flag resets to the default config",
current: service(api.LabelCaddyManaged, api.LabelCaddyConfigCustom),
opts: deployOptions{caddyfileChanged: true},
want: client.CaddyDeploymentOptions{Placement: machines},
},
{
name: "machine flag overrides the current machines",
current: service(api.LabelCaddyManaged),
opts: deployOptions{machines: []string{"m3,m4", "m5"}, machinesChanged: true},
want: client.CaddyDeploymentOptions{Placement: api.Placement{Machines: []string{"m3", "m4", "m5"}}},
},
{
name: "empty machine flag resets to all machines",
current: service(api.LabelCaddyManaged),
opts: deployOptions{machines: []string{}, machinesChanged: true},
},
{
name: "flags apply to a service that isn't managed",
current: service(),
opts: deployOptions{
image: "caddy:2", imageChanged: true,
caddyfile: "global.Caddyfile", caddyfileChanged: true,
machines: []string{"m3"}, machinesChanged: true,
},
config: customConfig,
want: client.CaddyDeploymentOptions{
Image: "caddy:2",
Config: new(customConfig),
Placement: api.Placement{Machines: []string{"m3"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, resolveDeploymentOptions(tt.current, tt.opts, tt.config))
})
}
}
func TestDeploymentWarnings(t *testing.T) {
t.Parallel()
const (
defaultImage = client.CaddyImage + ":0.1.3"
customImage = "example.com/caddy:1.2.3"
localConfig = "{\n\tdebug\n}"
override = "doesn't manage this caddy service yet"
storage = "certificate storage changes from"
keepStorage = "To keep the current storage"
module = "must include the Uncloud storage module"
)
managed := map[string]string{api.LabelCaddyManaged: ""}
service := func(image, config string, labels map[string]string) api.ServiceSpec {
spec := api.ServiceSpec{
Name: client.CaddyServiceName,
Container: api.ContainerSpec{Image: image},
Labels: labels,
}
if config != "" {
spec.Caddy = &api.CaddySpec{Config: config}
}
return spec
}
tests := []struct {
name string
current api.ServiceSpec
target api.ServiceSpec
opts deployOptions
want []string
notWant []string
}{
{
name: "new service with defaults has no warnings",
target: service(defaultImage, client.DefaultCaddyConfig, managed),
notWant: []string{override, storage, module},
},
{
name: "new service with a custom image",
target: service(customImage, client.DefaultCaddyConfig, managed),
want: []string{module},
notWant: []string{override, storage},
},
{
name: "service that isn't managed moves to cluster storage",
current: service("caddy:2.11.4", "", nil),
target: service(defaultImage, client.DefaultCaddyConfig, managed),
want: []string{override, "from local to cluster", keepStorage},
notWant: []string{module},
},
{
name: "service that isn't managed already uses cluster storage",
current: service(defaultImage, client.DefaultCaddyConfig, nil),
target: service(defaultImage, client.DefaultCaddyConfig, managed),
want: []string{override},
notWant: []string{storage, module},
},
{
name: "managed service without changes has no warnings",
current: service(defaultImage, client.DefaultCaddyConfig, managed),
target: service(defaultImage, client.DefaultCaddyConfig, managed),
notWant: []string{override, storage, module},
},
{
name: "managed service upgrades the default image",
current: service(client.CaddyImage+":0.1.2", client.DefaultCaddyConfig, managed),
target: service(defaultImage, client.DefaultCaddyConfig, managed),
notWant: []string{override, storage, module},
},
{
name: "custom caddyfile moves to local storage",
current: service(defaultImage, client.DefaultCaddyConfig, managed),
target: service(defaultImage, localConfig, managed),
opts: deployOptions{caddyfile: "global.Caddyfile", caddyfileChanged: true},
want: []string{"from cluster to local"},
notWant: []string{override, keepStorage, module},
},
{
name: "other storage module is named",
current: service(defaultImage, "{\n\tstorage redis\n}", managed),
target: service(defaultImage, client.DefaultCaddyConfig, managed),
want: []string{"from 'redis' to cluster", keepStorage},
},
{
name: "custom image is unchanged with the same config",
current: service(customImage, client.DefaultCaddyConfig, managed),
target: service(customImage, client.DefaultCaddyConfig, managed),
notWant: []string{override, storage, module},
},
{
name: "custom image receives a changed default config",
current: service(customImage, "{\n\tstorage uncloud\n\tdebug\n}", managed),
target: service(customImage, client.DefaultCaddyConfig, managed),
want: []string{module},
notWant: []string{override, storage},
},
{
name: "custom image with local storage",
current: service(defaultImage, localConfig, managed),
target: service(customImage, localConfig, managed),
notWant: []string{override, storage, module},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
warnings := strings.Join(deploymentWarnings(tt.current, tt.target, tt.opts), "\n")
for _, want := range tt.want {
assert.Contains(t, warnings, want)
}
for _, notWant := range tt.notWant {
assert.NotContains(t, warnings, notWant)
}
})
}
}
-29
View File
@@ -1,29 +0,0 @@
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
}
-103
View File
@@ -1,103 +0,0 @@
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)
})
}
}
-72
View File
@@ -1,72 +0,0 @@
package dns
import (
"context"
"errors"
"fmt"
"github.com/miekg/dns"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/grpcversion"
"github.com/spf13/cobra"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func NewSetCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "set DOMAIN_NAME",
Args: cobra.ExactArgs(1),
Short: "Set or unset an externally managed cluster domain (EXPERIMENTAL).",
Long: "EXPERIMENTAL: Set the cluster domain used to generate ingress hostnames for services.\n" +
"Configure wildcard DNS records for this domain with your DNS provider. " +
"Uncloud will not create, verify, update, or delete external DNS records.\n" +
"Pass an empty string to unset a manually set domain. " +
"Use 'uc dns release' to release a domain reserved in Uncloud DNS. " +
"Setting or unsetting the domain does not change existing service hostnames.",
Example: " uc dns set apps.example.com\n uc dns set \"\"",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return set(cmd.Context(), uncli, args[0])
},
}
return cmd
}
func set(ctx context.Context, uncli *cli.CLI, name string) error {
if name != "" {
if labels, ok := dns.IsDomainName(name); !ok || labels < 2 {
return fmt.Errorf("invalid cluster domain %q: must be a valid domain name with at least two labels", name)
}
}
tui.PrintWarning("Setting an externally managed cluster domain is experimental.")
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
ctx = metadata.AppendToOutgoingContext(ctx, grpcversion.MetadataKeyMinServerVersion, "0.21.0")
_, err = clusterClient.SetDomain(ctx, &pb.SetDomainRequest{Name: name})
if err != nil {
if status.Convert(err).Code() == codes.AlreadyExists {
return errors.New("cluster domain already configured, unset it with 'uc dns set \"\"' or " +
"release a reservation with 'uc dns release' first")
}
return fmt.Errorf("set cluster domain: %w", err)
}
if name == "" {
fmt.Println("Unset cluster domain. External DNS records have not been changed.")
return nil
}
fmt.Printf("Set cluster domain: %s\n", name)
fmt.Println("DNS is managed externally. " +
"Configure wildcard DNS records pointing to machines running the Caddy service.")
return nil
}
-137
View File
@@ -1,137 +0,0 @@
package image
import (
"context"
"errors"
"fmt"
"os"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra"
)
type removeOptions struct {
machines []string
yes bool
}
func NewRemoveCommand() *cobra.Command {
opts := removeOptions{}
cmd := &cobra.Command{
Use: "rm IMAGE [IMAGE...]",
Aliases: []string{"remove", "delete"},
Short: "Remove images from machines in the cluster.",
Long: `Remove images from all or selected machines in the cluster.
An image name without a tag selects all its tags. Specify a tag, such as myapp:latest, to remove
only that tag.
Wildcard patterns are supported for image names and tags and should be quoted to prevent your
shell from expanding them. Removing by image ID attempts to remove each name for that image
independently.
Images used by running or stopped containers cannot be removed. Unused tags can be removed even
when the same image is used under another tag. Failed removals do not prevent other removals.`,
Example: ` # Remove a tag from all machines after confirming the plan.
uc image rm myapp:old
# Remove all tags for an image name from all machines.
uc image rm myapp
# Remove matching tags from two machines without confirmation.
uc image rm 'myapp:1.*' 'otherapp:*' -m machine1,machine2 --yes
# Remove all names for an image by ID from all machines.
uc image rm ee8fed0e9618`,
Args: cobra.MinimumNArgs(1),
PreRun: func(cmd *cobra.Command, args []string) {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
},
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return remove(cmd.Context(), uncli, args, opts)
},
}
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Machine names or IDs to remove images from. Can be specified multiple times or as a comma-separated list.\n"+
"If not specified, the matching images will be removed from all machines.")
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm the removal plan. Should be explicitly set when running non-interactively,\n"+
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
completion.MachinesFlag(cmd)
return cmd
}
func remove(ctx context.Context, uncli *cli.CLI, images []string, opts removeOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
plan, err := planImageRemoval(ctx, clusterClient, images, cli.ExpandCommaSeparatedValues(opts.machines))
if err != nil {
return fmt.Errorf("plan image removal: %w", err)
}
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
cluster := ""
if directConn != "" {
cluster = directConn
lipgloss.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
lipgloss.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
cluster = contextName
lipgloss.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
lipgloss.Println()
}
lipgloss.Println(plan.Format())
if !opts.yes {
if !tui.IsTerminalAvailable() {
return errors.New("cannot ask to confirm image removal in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
}
title := "Proceed with image removal?"
if cluster != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with image removal on " + tui.NameStyle.Render(cluster) + confirmStyle.Render("?")
}
confirmed, err := tui.Confirm(title)
if err != nil {
return fmt.Errorf("confirm image removal: %w", err)
}
if !confirmed {
return cli.Cancelled("Image removal cancelled. No images were removed.")
}
}
untagged := make(map[string]bool)
var errs []error
for i, group := range plan.GroupPlans {
if i > 0 {
fmt.Println()
}
err := progress.RunWithTitle(ctx, func(ctx context.Context) error {
return group.Execute(ctx, clusterClient, untagged)
}, uncli.ProgressOut(), "Removing image "+group.Image)
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
-426
View File
@@ -1,426 +0,0 @@
package image
import (
"context"
"errors"
"fmt"
"path"
"regexp"
"slices"
"strings"
"charm.land/lipgloss/v2"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
)
var imageIDRegex = regexp.MustCompile(`^(sha256:)?[a-f0-9]{4,64}$`)
// imageRemovalPlan contains all image removal operations grouped by the user-supplied image name, ID, or pattern.
type imageRemovalPlan struct {
GroupPlans []*removeImageGroupPlan
}
func (p *imageRemovalPlan) IsEmpty() bool { return len(p.GroupPlans) == 0 }
func (p *imageRemovalPlan) Format() string {
var out strings.Builder
machines := make(map[string]struct{})
removeCount := 0
for _, group := range p.GroupPlans {
out.WriteString(group.Format())
for _, op := range group.Operations {
machines[op.MachineName] = struct{}{}
removeCount++
}
}
summary := fmt.Sprintf("%s remove %s across %s %s",
tui.BoldRed.Render(fmt.Sprint(removeCount)),
tui.Faint.Render("·"),
tui.Bold.Render(fmt.Sprint(len(machines))),
pluralMachines(len(machines)),
)
out.WriteString("\n" + tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))) + "\n" + summary + "\n")
return out.String()
}
func pluralMachines(count int) string {
if count == 1 {
return "machine"
}
return "machines"
}
// Execute applies the frozen plan, collecting failures without discovering new targets.
func (p *imageRemovalPlan) Execute(ctx context.Context, cli api.ImageClient) error {
untagged := make(map[string]bool)
var errs []error
for _, group := range p.GroupPlans {
if err := group.Execute(ctx, cli, untagged); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// removeImageGroupPlan groups removals for one image name, ID, or pattern supplied by the user.
type removeImageGroupPlan struct {
// Image is the image name, ID, or pattern supplied by the user.
Image string
Operations []*removeImageOperation
}
func (o *removeImageGroupPlan) Execute(ctx context.Context, cli api.ImageClient, untagged map[string]bool) error {
var errs []error
for _, op := range o.Operations {
if err := ctx.Err(); err != nil {
return errors.Join(append(errs, err)...)
}
if err := op.Execute(ctx, cli, untagged); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (o *removeImageGroupPlan) Format() string {
var out strings.Builder
out.WriteString(tui.BoldRed.Render("-") + " remove image " + tui.NameStyle.Render(o.Image) + "\n")
for i, op := range o.Operations {
branch := " ├── "
if i == len(o.Operations)-1 {
branch = " ╰── "
}
out.WriteString(tui.Faint.Render(branch) + op.Format() + "\n")
}
return out.String()
}
func (o *removeImageGroupPlan) String() string {
return fmt.Sprintf("removeImageGroupPlan[image=%s]", o.Image)
}
// removeImageOperation removes an image name or an unnamed image by ID on a machine.
type removeImageOperation struct {
MachineName string
ImageID string
// Image is the exact image name or ID sent to Docker.
Image string
}
func (o *removeImageOperation) Execute(ctx context.Context, cli api.ImageClient, untagged map[string]bool) error {
if untagged[o.MachineName+"\x00"+o.Image] {
return nil
}
results, err := cli.RemoveImage(ctx, o.Image, api.RemoveImageOptions{Machines: []string{o.MachineName}})
// A removal may also untag other planned names. Track actual results across all groups, per machine.
for _, result := range results {
for _, removal := range result.Results {
if removal.Untagged != "" {
untagged[result.Metadata.MachineName+"\x00"+removal.Untagged] = true
}
}
}
if errors.Is(err, api.ErrNotFound) {
return nil
}
if err != nil {
return fmt.Errorf("remove image '%s': %w", o.Image, err)
}
return nil
}
func (o *removeImageOperation) Format() string {
return tui.BoldRed.Render("-") + " " + tui.FormatImage(o.Image, tui.NoStyle) +
tui.Faint.Render(" ("+stringid.TruncateID(o.ImageID)+") on ") + o.MachineName
}
func (o *removeImageOperation) String() string {
return fmt.Sprintf("removeImageOperation[machine_name=%s image=%s]", o.MachineName, o.Image)
}
type machineImageInventory struct {
machineName string
images []image.Summary
}
// planImageRemoval builds a removal plan grouped by input argument from images on the selected machines.
// An empty machines list selects all machines.
//
// Arguments expand as follows:
// - Name: without a tag or digest, select all stored names in that exact repository across image IDs.
// An explicit tag selects only that tag. Familiar names are normalised.
// - ID: select all RepoTags for the matching image, or its full ID if unnamed. A prefix must identify
// one unique full ID across the selected machines.
// - Pattern: match RepoTags only. A matching digest also selects that image's names in the same repository.
// - Digest: resolve through RepoTags, RepoDigests, or the image ID, then select that image's names in
// the same repository. If none exist but RepoDigests contains the reference, select the digest itself.
//
// Selecting all tags for a repository/image on a machine also adds its stored digest references after
// the tags, even if those digests did not match an argument. Stored names are kept as is, and overlapping
// selections produce only one operation per machine and name.
//
// Failed image listings produce warnings and exclude those machines from the plan. If no machine can
// be queried, planning fails. Every argument must match on a successfully queried machine. Unmatched
// or invalid arguments and ambiguous ID prefixes reject the whole plan before any removal.
//
// Planning does not check container usage. The backend checks each removal during execution, and failures
// do not stop other operations. Tag removal may implicitly untag a used digest. Execution skips names
// already untagged by earlier operations and tolerates names that disappeared after planning.
func planImageRemoval(ctx context.Context, cli api.ImageClient, images, machines []string) (*imageRemovalPlan, error) {
if len(images) == 0 {
return nil, errors.New("at least one image is required")
}
var clusterImages []api.MachineImages
err := tui.RunSpinner(ctx, "Finding images to remove...", func(ctx context.Context) error {
var err error
clusterImages, err = cli.ListImages(ctx, api.ImageFilter{Machines: machines})
return err
})
if err != nil {
return nil, fmt.Errorf("list images: %w", err)
}
var inventory []*machineImageInventory
for _, machineImages := range clusterImages {
if err := machineImages.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
machineImages.Metadata.MachineName, err))
continue
}
machine := &machineImageInventory{
machineName: machineImages.Metadata.MachineName,
images: machineImages.Images,
}
inventory = append(inventory, machine)
}
if len(inventory) == 0 {
return nil, errors.New("failed to list images on any machine")
}
return planImageRemovalFromInventory(images, inventory)
}
func planImageRemovalFromInventory(images []string, inventory []*machineImageInventory) (*imageRemovalPlan, error) {
plan := &imageRemovalPlan{}
seen := make(map[string]bool)
var errs []error
for _, imageArg := range images {
group := &removeImageGroupPlan{Image: imageArg}
isID := imageIDRegex.MatchString(imageArg)
isPattern := strings.ContainsAny(imageArg, "*?[")
var namedRef reference.Named
if isPattern {
if _, err := path.Match(imageArg, ""); err != nil {
return nil, fmt.Errorf("invalid image pattern '%s': %w", imageArg, err)
}
} else if !isID {
var err error
namedRef, err = reference.ParseNormalizedNamed(imageArg)
if err != nil {
return nil, fmt.Errorf("invalid image '%s': %w", imageArg, err)
}
}
if isID {
matches := make(map[string]bool)
for _, machine := range inventory {
for _, img := range machine.images {
if imageIDMatches(img.ID, imageArg) {
matches[img.ID] = true
}
}
}
if len(matches) > 1 {
var candidates []string
for id := range matches {
candidates = append(candidates, id)
}
slices.Sort(candidates)
errs = append(errs,
fmt.Errorf("image ID '%s' is ambiguous: %s", imageArg, strings.Join(candidates, ", ")))
continue
}
}
matched := false
for _, machine := range inventory {
for _, img := range machine.images {
var imagesToRemove []string
switch {
case isID:
if !imageIDMatches(img.ID, imageArg) {
continue
}
matched = true
// Expand only stored names, matching uc image ls. RepoDigests remains available for digest lookup.
imagesToRemove = img.RepoTags
if len(imagesToRemove) == 0 {
imagesToRemove = []string{img.ID}
}
case isPattern:
for _, ref := range img.RepoTags {
if ok, _ := path.Match(imageArg, ref); ok {
named, _ := reference.ParseDockerRef(ref)
if _, digest := named.(reference.Canonical); digest {
imagesToRemove = append(imagesToRemove,
imageRepositoryReferences(img.RepoTags, named.Name())...)
} else {
imagesToRemove = append(imagesToRemove, ref)
}
}
}
case reference.IsNameOnly(namedRef):
imagesToRemove = imageRepositoryReferences(img.RepoTags, namedRef.Name())
default:
if canonical, digest := namedRef.(reference.Canonical); digest {
ref := reference.FamiliarString(namedRef)
if canonical.Digest().String() != img.ID && !slices.Contains(img.RepoTags, ref) &&
!slices.Contains(img.RepoDigests, ref) {
continue
}
imagesToRemove = imageRepositoryReferences(img.RepoTags, namedRef.Name())
if len(imagesToRemove) == 0 && slices.Contains(img.RepoDigests, ref) {
// An explicitly requested digest can resolve even when no stored name is listed.
imagesToRemove = []string{ref}
}
} else if slices.Contains(img.RepoTags, reference.FamiliarString(namedRef)) {
imagesToRemove = []string{reference.FamiliarString(namedRef)}
}
}
for _, ref := range imagesToRemove {
matched = true
key := machine.machineName + "\x00" + ref
if !seen[key] {
seen[key] = true
group.Operations = append(group.Operations, &removeImageOperation{
MachineName: machine.machineName, ImageID: img.ID, Image: ref,
})
}
}
}
}
if !matched {
errs = append(errs, fmt.Errorf("image '%s' not found on any machine", imageArg))
}
slices.SortFunc(group.Operations, compareImageRemovalOperations)
if len(group.Operations) > 0 {
plan.GroupPlans = append(plan.GroupPlans, group)
}
}
planDigestRemovals(plan, inventory)
if err := errors.Join(errs...); err != nil {
return nil, err
}
return plan, nil
}
func imageRepositoryReferences(refs []string, repository string) []string {
var matches []string
for _, ref := range refs {
if named, err := reference.ParseDockerRef(ref); err == nil && named.Name() == repository {
matches = append(matches, ref)
}
}
return matches
}
// planDigestRemovals includes stored digests with the group removing their last repository tag.
// RepoDigests also contains synthesised references, so only digest names stored in RepoTags get operations here.
func planDigestRemovals(plan *imageRemovalPlan, inventory []*machineImageInventory) {
type repository struct {
remainingTags int
digests []string
}
repositories := make(map[string]*repository)
for _, machine := range inventory {
for _, img := range machine.images {
names := slices.Clone(img.RepoTags)
slices.Sort(names)
for _, ref := range slices.Compact(names) {
named, err := reference.ParseDockerRef(ref)
if err != nil {
continue
}
key := machine.machineName + "\x00" + img.ID + "\x00" + named.Name()
repo := repositories[key]
if repo == nil {
repo = &repository{}
repositories[key] = repo
}
if _, tagged := named.(reference.Tagged); tagged {
repo.remainingTags++
} else if _, digest := named.(reference.Canonical); digest {
repo.digests = append(repo.digests, ref)
}
}
}
}
emitted := make(map[string]bool)
var groups []*removeImageGroupPlan
for _, group := range plan.GroupPlans {
var operations []*removeImageOperation
for _, op := range group.Operations {
key := op.MachineName + "\x00" + op.Image
if emitted[key] {
continue
}
emitted[key] = true
operations = append(operations, op)
named, err := reference.ParseDockerRef(op.Image)
if err != nil {
continue
}
if _, tagged := named.(reference.Tagged); !tagged {
continue
}
repo := repositories[op.MachineName+"\x00"+op.ImageID+"\x00"+named.Name()]
if repo == nil {
continue
}
repo.remainingTags--
if repo.remainingTags != 0 {
continue
}
for _, digest := range repo.digests {
key := op.MachineName + "\x00" + digest
if !emitted[key] {
emitted[key] = true
operations = append(operations, &removeImageOperation{
MachineName: op.MachineName, ImageID: op.ImageID, Image: digest,
})
}
}
}
group.Operations = operations
slices.SortFunc(group.Operations, compareImageRemovalOperations)
if len(group.Operations) > 0 {
groups = append(groups, group)
}
}
plan.GroupPlans = groups
}
func compareImageRemovalOperations(a, b *removeImageOperation) int {
aDigest, bDigest := strings.Contains(a.Image, "@"), strings.Contains(b.Image, "@")
if aDigest != bDigest {
if aDigest {
return 1
}
return -1
}
if c := strings.Compare(a.Image, b.Image); c != 0 {
return c
}
return strings.Compare(a.MachineName, b.MachineName)
}
func imageIDMatches(id, prefix string) bool {
return strings.HasPrefix(strings.TrimPrefix(id, "sha256:"), strings.TrimPrefix(prefix, "sha256:"))
}
-405
View File
@@ -1,405 +0,0 @@
package image
import (
"strings"
"testing"
"github.com/docker/docker/api/types/image"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPlanImageRemovalFromInventory_Selectors(t *testing.T) {
idA, idB := testImageID("a"), testImageID("b")
digestRef := "myapp@" + testImageID("c")
tests := []struct {
name string
selector string
inventory []*machineImageInventory
want []removeImageOperation
}{
{
name: "repository selects all stored names across images and machines", selector: "myapp",
inventory: []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "other:keep"),
testImage(idB, "myapp:latest"),
testImage(testImageID("c"), digestRef),
testImage(testImageID("d"), "myapp-extra:latest", "team/myapp:latest", "myapp/worker:latest"),
}),
testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:test")}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idB, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idB, Image: "myapp:test"},
{MachineName: "dev1", ImageID: testImageID("c"), Image: digestRef},
},
},
{
name: "repository without latest includes its tags and stored digest", selector: "myapp",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", digestRef, "other:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "explicit latest selects only that tag", selector: "myapp:latest",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}},
},
{
name: "qualified repository selects familiar names", selector: "docker.io/library/myapp",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", "other:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
},
},
{
name: "registry port does not count as an explicit tag", selector: "registry.example.com:5000/team/myapp",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new",
"registry.example.com:5001/team/myapp:keep", "registry.example.com:5000/team/myapp/worker:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:new"},
{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"},
},
},
{
name: "explicit tag with registry port stays exact", selector: "registry.example.com:5000/team/myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new"),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"}},
},
{
name: "tag uses each machines local image and allows missing copies", selector: "myapp:old",
inventory: []*machineImageInventory{
testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:old")}),
testMachineInventory("empty", nil),
testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idB, Image: "myapp:old"},
},
},
{
name: "qualified names use familiar references", selector: "docker.io/library/myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old"),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "full ID expands all repositories and deduplicates references per machine", selector: idA,
inventory: []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{
{
ID: idA,
RepoTags: []string{"other:keep", "myapp:old", "myapp:old", digestRef, "other@" + testImageID("c")},
RepoDigests: []string{
digestRef, "other@" + testImageID("c"), "other@" + testImageID("c"), testImageID("c"),
},
},
testImage(idB, "unrelated:keep"),
}),
testMachineInventory("machine2", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", digestRef}, RepoDigests: []string{digestRef},
}}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: "other:keep"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
{MachineName: "machine2", ImageID: idA, Image: digestRef},
{MachineName: "dev1", ImageID: idA, Image: "other@" + testImageID("c")},
},
},
{
name: "same full ID on multiple machines is unambiguous", selector: "aaaaaaaa",
inventory: []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}),
testMachineInventory("dev1", []image.Summary{testImage(idB, "myapp:latest")}),
testMachineInventory("machine2", []image.Summary{testImage(idA, "other:test")}),
},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "machine2", ImageID: idA, Image: "other:test"},
},
},
{
name: "short ID with algorithm prefix", selector: "sha256:aaaaaaaa",
inventory: []*machineImageInventory{testMachineInventory("dev1",
[]image.Summary{testImage(idA, "myapp:old")})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "unnamed image is removed by its full ID", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ID: idA}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}},
},
{
name: "ID expands only stored names", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:latest"},
RepoDigests: []string{"myapp@" + idA, "other@" + idA},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}},
},
{
name: "ID removes an untagged image by ID even if RepoDigests is populated", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}},
},
{
name: "synthesized digest selects repository tags without a digest row", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", "other:keep", "myapp:latest"},
RepoDigests: []string{digestRef, "other@" + testImageID("c")},
}})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
},
},
{
name: "explicit digest lookup works without stored names", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
},
{
name: "synthesized digest resolves by target and repository even if not listed", selector: "myapp@" + idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{"other@" + idA},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "stored digest follows its repository tags", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"),
testImage(idB, "myapp@"+idB),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "digest pattern also expands matching repository tags", selector: "myapp@sha256:c*",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"),
testImage(idB, "myapp@"+idB),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "digest pattern matches stored names and ignores derived names", selector: "myapp@sha256:*",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", digestRef),
{ID: idB, RepoTags: []string{"myapp:latest"}, RepoDigests: []string{"myapp@" + idB}},
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "last repository tag includes its stored digest", selector: "myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", digestRef, "other:keep"),
})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
{MachineName: "dev1", ImageID: idA, Image: digestRef},
},
},
{
name: "last tag does not add a synthesized digest row", selector: "myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "remaining repository tag preserves its stored digest", selector: "myapp:old",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, "myapp:old", "myapp:latest", digestRef),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
},
{
name: "digest without repository tags remains executable", selector: digestRef,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(idA, digestRef, "other:keep"),
})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
},
{
name: "digest-only image keeps its named reference", selector: idA,
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{digestRef}, RepoDigests: []string{digestRef, testImageID("c")},
}})},
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
},
{
name: "tag pattern selects only matching tags", selector: "myapp:1.[12]",
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:1.3", "myapp:1.2", "myapp:1.1"}, RepoDigests: []string{digestRef},
}})},
want: []removeImageOperation{
{MachineName: "dev1", ImageID: idA, Image: "myapp:1.1"},
{MachineName: "dev1", ImageID: idA, Image: "myapp:1.2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan, err := planImageRemovalFromInventory([]string{tt.selector}, tt.inventory)
require.NoError(t, err)
require.Len(t, plan.GroupPlans, 1)
assert.Equal(t, tt.selector, plan.GroupPlans[0].Image)
var got []removeImageOperation
for _, op := range plan.GroupPlans[0].Operations {
got = append(got, *op)
}
assert.Equal(t, tt.want, got)
})
}
}
func TestPlanImageRemovalFromInventory_OverlappingSelectors(t *testing.T) {
inventory := []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{testImage(testImageID("a"), "myapp:old", "myapp:latest")}),
testMachineInventory("machine2", []image.Summary{testImage(testImageID("b"), "myapp:test", "myapp:old")}),
}
plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp", "myapp:*", testImageID("a"), "myapp:old"},
inventory)
require.NoError(t, err)
assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{
{Image: "myapp:old", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:old"},
{MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:old"},
}},
{Image: "myapp", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:latest"},
{MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:test"},
}},
}}, plan, "the first selector owns each removal and empty groups are omitted")
}
func TestPlanImageRemovalFromInventory_DigestFollowsLastTagAcrossSelectors(t *testing.T) {
imageID := testImageID("a")
digestRef := "myapp@" + imageID
inventory := []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
testImage(imageID, "myapp:old", "myapp:latest", "myapp:old", digestRef, digestRef, "other:keep"),
})}
plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp:latest", digestRef}, inventory)
require.NoError(t, err)
assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{
{Image: "myapp:old", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: imageID, Image: "myapp:old"},
}},
{Image: "myapp:latest", Operations: []*removeImageOperation{
{MachineName: "dev1", ImageID: imageID, Image: "myapp:latest"},
{MachineName: "dev1", ImageID: imageID, Image: digestRef},
}},
}}, plan, "digest cleanup belongs to the last tag removal, so the later digest selector has no extra operation")
}
func TestPlanImageRemovalFromInventory_RejectsInvalidOrMissingSelectors(t *testing.T) {
idA := "sha256:abcdef" + strings.Repeat("a", 58)
idB := "sha256:abcdef" + strings.Repeat("b", 58)
inventory := []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{{
ID: idA, RepoTags: []string{"myapp:old"}, RepoDigests: []string{"myapp@" + idA, "derived@" + idA},
}}),
testMachineInventory("machine2", []image.Summary{{ID: idB, RepoTags: []string{"myapp:old"}}}),
}
tests := []struct {
name string
images []string
err string
}{
{"missing repository", []string{"missing"}, "not found"},
{"repository only has a derived digest", []string{"derived"}, "not found"},
{"missing tag", []string{"myapp:missing"}, "not found"},
{"missing pattern", []string{"other:*"}, "not found"},
{"pattern only matches a derived digest", []string{"myapp@sha256:*"}, "not found"},
{"missing ID", []string{testImageID("c")}, "not found"},
{"ambiguous ID prefix", []string{"abcdef"}, "ambiguous"},
{"invalid name", []string{"MyApp:old"}, "invalid image"},
{"invalid pattern", []string{"myapp:["}, "invalid image pattern"},
{"one missing selector rejects the whole plan", []string{"myapp:old", "myapp:missing"}, "not found"},
{"one invalid selector rejects the whole plan", []string{"myapp:old", "myapp:["}, "invalid image pattern"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan, err := planImageRemovalFromInventory(tt.images, inventory)
require.ErrorContains(t, err, tt.err)
assert.Nil(t, plan, "a failed selector must not return a partial plan")
})
}
t.Run("ID prefix resolves within the supplied machines", func(t *testing.T) {
plan, err := planImageRemovalFromInventory([]string{"abcdef"}, inventory[:1])
require.NoError(t, err)
require.Len(t, plan.GroupPlans, 1)
require.Len(t, plan.GroupPlans[0].Operations, 1)
assert.Equal(t, idA, plan.GroupPlans[0].Operations[0].ImageID)
})
}
func TestPlanImageRemovalFromInventory_InUseImagesAreStillPlanned(t *testing.T) {
img := testImage(testImageID("a"), "myapp:old", "myapp:current")
img.Containers = 1
for _, selector := range []string{"myapp", "myapp:current", img.ID, "myapp:*"} {
t.Run(selector, func(t *testing.T) {
plan, err := planImageRemovalFromInventory([]string{selector}, []*machineImageInventory{
testMachineInventory("dev1", []image.Summary{img}),
})
require.NoError(t, err)
assert.False(t, plan.IsEmpty(), "usage protection belongs to the backend at execution time")
})
}
}
func testImageID(char string) string { return "sha256:" + strings.Repeat(char, 64) }
func testImage(imageID string, tags ...string) image.Summary {
return image.Summary{ID: imageID, RepoTags: tags}
}
func testMachineInventory(name string, images []image.Summary) *machineImageInventory {
return &machineImageInventory{machineName: name, images: images}
}
-72
View File
@@ -1,72 +0,0 @@
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)
}
-25
View File
@@ -1,25 +0,0 @@
package machine
import (
"context"
"testing"
"github.com/psviderski/uncloud/internal/cli/logs"
"github.com/stretchr/testify/require"
)
func TestRunLogsInvalidTimeFilters(t *testing.T) {
t.Parallel()
// Invalid filters must fail before connecting to the cluster.
for _, flag := range []string{"since", "until"} {
opts := logs.Options{}
if flag == "since" {
opts.Since = "invalid"
} else {
opts.Until = "invalid"
}
err := runLogs(context.Background(), nil, nil, opts)
require.ErrorContains(t, err, "invalid --"+flag+" value")
}
}
-116
View File
@@ -1,116 +0,0 @@
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
@@ -1,175 +0,0 @@
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 ""
}
}
-105
View File
@@ -1,105 +0,0 @@
package main
import (
"context"
"encoding/json"
"testing"
"github.com/psviderski/uncloud/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)
}
}
}
-25
View File
@@ -1,25 +0,0 @@
package service
import (
"context"
"testing"
"github.com/psviderski/uncloud/internal/cli/logs"
"github.com/stretchr/testify/require"
)
func TestRunLogsInvalidTimeFilters(t *testing.T) {
t.Parallel()
// Invalid filters must fail before loading Compose files or connecting to the cluster.
for _, flag := range []string{"since", "until"} {
opts := logs.Options{}
if flag == "since" {
opts.Since = "invalid"
} else {
opts.Until = "invalid"
}
err := RunLogs(context.Background(), nil, nil, opts)
require.ErrorContains(t, err, "invalid --"+flag+" value")
}
}
-423
View File
@@ -1,423 +0,0 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// validRunOptions returns the minimal options that produce a valid service spec.
func validRunOptions() runOptions {
return runOptions{
image: "nginx:1.27",
name: "web",
mode: api.ServiceModeReplicated,
pull: api.PullPolicyMissing,
replicas: 1,
}
}
func TestParseLabels(t *testing.T) {
t.Parallel()
labels, err := parseLabels(nil)
require.NoError(t, err)
assert.Nil(t, labels)
labels, err = parseLabels([]string{"tier=frontend", "empty", "list=a,b", "expr=a=b", "tier=backend"})
require.NoError(t, err)
assert.Equal(t, map[string]string{
"tier": "backend",
"empty": "",
"list": "a,b",
"expr": "a=b",
}, labels)
_, err = parseLabels([]string{"=value"})
require.ErrorContains(t, err, "key must not be empty")
_, err = parseLabels([]string{" \t =value"})
require.ErrorContains(t, err, "key must not be empty")
labels, err = parseLabels([]string{"key=value with spaces"})
require.NoError(t, err)
assert.Equal(t, map[string]string{"key": "value with spaces"}, labels)
}
func TestPrepareServiceSpec(t *testing.T) {
t.Parallel()
t.Run("minimal options", func(t *testing.T) {
t.Parallel()
spec, err := prepareServiceSpec(validRunOptions())
require.NoError(t, err)
assert.Equal(t, "web", spec.Name)
assert.Equal(t, api.ServiceModeReplicated, spec.Mode)
assert.EqualValues(t, 1, spec.Replicas)
assert.Equal(t, "nginx:1.27", spec.Container.Image)
assert.Equal(t, api.PullPolicyMissing, spec.Container.PullPolicy)
assert.Nil(t, spec.Caddy)
assert.Nil(t, spec.Labels)
assert.Nil(t, spec.Container.Labels)
assert.Nil(t, spec.Container.Entrypoint)
assert.Nil(t, spec.Container.Resources.Ulimits)
assert.Nil(t, spec.Placement.Machines)
assert.Empty(t, spec.Container.Env)
assert.Empty(t, spec.Ports)
assert.Empty(t, spec.Volumes)
assert.Empty(t, spec.Container.VolumeMounts)
})
t.Run("all options", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.command = []string{"nginx", "-g", "daemon off;"}
opts.env = []string{"FOO=bar", "EMPTY=", "EXPR=a=b"}
opts.labels = []string{"tier=frontend"}
opts.containerLabels = []string{"logging=enabled"}
opts.machines = []string{"m1,m2", "m3"}
opts.privileged = true
opts.pull = api.PullPolicyAlways
opts.publish = []string{"app.example.com:8080/https", "53:5353/udp@host"}
opts.replicas = 3
opts.ulimits = []string{"nofile=1024:2048", "nproc=65535"}
opts.user = "1000:1000"
opts.volumes = []string{"data:/var/lib/data:ro"}
require.NoError(t, opts.cpu.Set("0.5"))
require.NoError(t, opts.memory.Set("128m"))
require.NoError(t, opts.memoryReservation.Set("32m"))
require.NoError(t, opts.shmSize.Set("64m"))
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, api.ServiceSpec{
Container: api.ContainerSpec{
Command: []string{"nginx", "-g", "daemon off;"},
Env: api.EnvVars{"FOO": "bar", "EMPTY": "", "EXPR": "a=b"},
Image: "nginx:1.27",
Labels: map[string]string{"logging": "enabled"},
Privileged: true,
PullPolicy: api.PullPolicyAlways,
Resources: api.ContainerResources{
CPU: 500 * api.MilliCore,
Memory: 128 * units.MiB,
MemoryReservation: 32 * units.MiB,
SharedMemory: 64 * units.MiB,
Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 1024, Hard: 2048},
"nproc": {Soft: 65535, Hard: 65535},
},
},
User: "1000:1000",
VolumeMounts: []api.VolumeMount{
{VolumeName: "data", ContainerPath: "/var/lib/data", ReadOnly: true},
},
},
Labels: map[string]string{"tier": "frontend"},
Mode: api.ServiceModeReplicated,
Name: "web",
Placement: api.Placement{Machines: []string{"m1", "m2", "m3"}},
Ports: []api.PortSpec{
{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: api.ProtocolHTTPS,
Mode: api.PortModeIngress,
},
{
PublishedPort: 53,
ContainerPort: 5353,
Protocol: api.ProtocolUDP,
Mode: api.PortModeHost,
},
},
Replicas: 3,
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{Name: "data"},
},
},
}, spec)
})
t.Run("generates a name when not specified", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.name = ""
opts.image = "bitnami/nginx:latest"
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Regexp(t, `^nginx-[a-z0-9]{4}$`, spec.Name)
})
t.Run("keeps the specified name", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.name = "custom"
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, "custom", spec.Name)
})
t.Run("global mode", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.mode = api.ServiceModeGlobal
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, api.ServiceModeGlobal, spec.Mode)
})
t.Run("entrypoint", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.entrypoint = "/bin/sh"
opts.entrypointChanged = true
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, []string{"/bin/sh"}, spec.Container.Entrypoint)
// An explicitly empty entrypoint resets the one from the image.
opts = validRunOptions()
opts.entrypointChanged = true
spec, err = prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, []string{""}, spec.Container.Entrypoint)
})
t.Run("env without a value is omitted when not set locally", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.env = []string{"UNCLOUD_TEST_ENV_THAT_IS_NOT_SET"}
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.NotContains(t, spec.Container.Env, "UNCLOUD_TEST_ENV_THAT_IS_NOT_SET")
})
t.Run("bind mount", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.volumes = []string{"/data/uploads:/app/uploads"}
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
require.Len(t, spec.Volumes, 1)
assert.Regexp(t, `^bind-[a-z0-9]{4}$`, spec.Volumes[0].Name)
assert.Equal(t, api.VolumeTypeBind, spec.Volumes[0].Type)
require.NotNil(t, spec.Volumes[0].BindOptions)
assert.Equal(t, "/data/uploads", spec.Volumes[0].BindOptions.HostPath)
assert.True(t, spec.Volumes[0].BindOptions.CreateHostPath)
require.Len(t, spec.Container.VolumeMounts, 1)
assert.Equal(t, spec.Volumes[0].Name, spec.Container.VolumeMounts[0].VolumeName)
assert.Equal(t, "/app/uploads", spec.Container.VolumeMounts[0].ContainerPath)
})
t.Run("named volume mounted twice creates one volume", func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
opts.volumes = []string{"data:/a", "data:/b"}
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Len(t, spec.Volumes, 1)
assert.Len(t, spec.Container.VolumeMounts, 2)
})
t.Run("caddyfile content is trimmed", func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "Caddyfile")
require.NoError(t, os.WriteFile(path, []byte("\nexample.com {\n}\n\n"), 0o600))
opts := validRunOptions()
opts.caddyfile = path
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
require.NotNil(t, spec.Caddy)
assert.Equal(t, "example.com {\n}", spec.Caddy.Config)
})
t.Run("empty caddyfile is ignored", func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "Caddyfile")
require.NoError(t, os.WriteFile(path, []byte(" \n"), 0o600))
opts := validRunOptions()
opts.caddyfile = path
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Nil(t, spec.Caddy)
})
t.Run("caddyfile can be used with host ports", func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "Caddyfile")
require.NoError(t, os.WriteFile(path, []byte("example.com {\n}"), 0o600))
opts := validRunOptions()
opts.caddyfile = path
opts.publish = []string{"53:5353/udp@host"}
_, err := prepareServiceSpec(opts)
require.NoError(t, err)
})
}
func TestPrepareServiceSpec_LocalEnv(t *testing.T) {
// Not parallel because t.Setenv changes the environment of the whole process.
t.Setenv("UNCLOUD_TEST_LOCAL_ENV", "from-local")
opts := validRunOptions()
opts.env = []string{"UNCLOUD_TEST_LOCAL_ENV"}
spec, err := prepareServiceSpec(opts)
require.NoError(t, err)
assert.Equal(t, "from-local", spec.Container.Env["UNCLOUD_TEST_LOCAL_ENV"])
}
func TestPrepareServiceSpec_Invalid(t *testing.T) {
t.Parallel()
caddyfile := filepath.Join(t.TempDir(), "Caddyfile")
require.NoError(t, os.WriteFile(caddyfile, []byte("example.com {\n}"), 0o600))
tests := []struct {
name string
modify func(opts *runOptions)
wantErr string
}{
{
name: "invalid image",
modify: func(opts *runOptions) { opts.image = "INVALID IMAGE" },
wantErr: "invalid image",
},
{
name: "invalid name",
modify: func(opts *runOptions) { opts.name = "Not_Valid" },
wantErr: "invalid service name",
},
{
name: "invalid mode",
modify: func(opts *runOptions) { opts.mode = "unknown" },
wantErr: "invalid replication mode: 'unknown'",
},
{
name: "invalid pull policy",
modify: func(opts *runOptions) { opts.pull = "sometimes" },
wantErr: "invalid pull policy: 'sometimes'",
},
{
name: "invalid env",
modify: func(opts *runOptions) { opts.env = []string{"=value"} },
wantErr: "invalid environment variable: '=value'",
},
{
name: "invalid port",
modify: func(opts *runOptions) { opts.publish = []string{"not-a-port"} },
wantErr: "invalid service port 'not-a-port'",
},
{
name: "invalid ulimit",
modify: func(opts *runOptions) { opts.ulimits = []string{"nofile"} },
wantErr: "invalid ulimit 'nofile'",
},
{
name: "volume without a container path",
modify: func(opts *runOptions) { opts.volumes = []string{"data"} },
wantErr: "invalid volume mount 'data'",
},
{
name: "volume with a relative container path",
modify: func(opts *runOptions) { opts.volumes = []string{"data:relative"} },
wantErr: "must be absolute path",
},
{
name: "volume with an unknown option",
modify: func(opts *runOptions) { opts.volumes = []string{"data:/data:unknown"} },
wantErr: "invalid option: 'unknown'",
},
{
name: "missing caddyfile",
modify: func(opts *runOptions) { opts.caddyfile = filepath.Join(os.TempDir(), "uncloud-test-no-such-file") },
wantErr: "read Caddyfile",
},
{
name: "caddyfile with an ingress port",
modify: func(opts *runOptions) {
opts.caddyfile = caddyfile
opts.publish = []string{"app.example.com:8080/https"}
},
wantErr: "cannot be specified simultaneously",
},
{
name: "service label without a key",
modify: func(opts *runOptions) { opts.labels = []string{"=value"} },
wantErr: "key must not be empty",
},
{
name: "container label without a key",
modify: func(opts *runOptions) { opts.containerLabels = []string{"=value"} },
wantErr: "key must not be empty",
},
{
name: "service label with whitespace",
modify: func(opts *runOptions) { opts.labels = []string{"my label=value"} },
wantErr: "invalid service labels: label 'my label' must not contain whitespace",
},
{
name: "container label with whitespace",
modify: func(opts *runOptions) { opts.containerLabels = []string{"my label=value"} },
wantErr: "invalid container labels: label 'my label' must not contain whitespace",
},
{
name: "reserved service label",
modify: func(opts *runOptions) { opts.labels = []string{api.LabelManaged + "=true"} },
wantErr: "invalid service labels",
},
{
name: "reserved container label",
modify: func(opts *runOptions) { opts.containerLabels = []string{api.LabelManaged + "=true"} },
wantErr: "invalid container labels",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
opts := validRunOptions()
tt.modify(&opts)
_, err := prepareServiceSpec(opts)
require.ErrorContains(t, err, tt.wantErr)
})
}
}
-183
View File
@@ -1,183 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"maps"
"slices"
dockeropts "github.com/docker/cli/opts"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/spf13/cobra"
)
type updateOptions struct {
service string
cpu dockeropts.NanoCPUs
cpuChanged bool
memory dockeropts.MemBytes
memoryChanged bool
memoryReservation dockeropts.MemBytes
reservationChanged bool
labelAdd []string
labelRm []string
}
func NewUpdateCommand() *cobra.Command {
opts := updateOptions{}
cmd := &cobra.Command{
Use: "update SERVICE",
Short: "Update a service in place without recreating its containers.",
Long: `Update service configuration in place without recreating its containers.
A CPU or memory limit can be changed but not removed. To remove a limit or change anything else,
redeploy the service with 'uc deploy'`,
Example: ` # Change the CPU and memory limits.
uc service update web --cpu 0.5 --memory 512m
# Add or change a service label and remove another one.
uc service update web --label-add tier=frontend --label-rm legacy`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0]
opts.cpuChanged = cmd.Flag("cpu").Changed
opts.memoryChanged = cmd.Flag("memory").Changed
opts.reservationChanged = cmd.Flag("memory-reservation").Changed
return update(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)
},
}
cmd.Flags().Var(&opts.cpu, "cpu",
"Maximum number of CPU cores a service container can use.\n"+
"Fractional values are allowed: 0.5 for half a core or 2.25 for two and a quarter cores.")
cmd.Flags().Var(&opts.memory, "memory",
"Maximum amount of memory a service container can use. Value is a positive integer with\n"+
"optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
cmd.Flags().Var(&opts.memoryReservation, "memory-reservation",
"Minimum amount of memory reserved for a service container. Value is a positive integer with\n"+
"optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.")
cmd.Flags().StringArrayVar(&opts.labelAdd, "label-add", nil,
"Add or change a service label. Can be specified multiple times.\n"+
"Format: key=value or just key for an empty value.")
cmd.Flags().StringArrayVar(&opts.labelRm, "label-rm", nil,
"Remove a service label by its key. Can be specified multiple times.")
return cmd
}
func update(ctx context.Context, uncli *cli.CLI, opts updateOptions) error {
if !opts.cpuChanged && !opts.memoryChanged && !opts.reservationChanged &&
len(opts.labelAdd) == 0 && len(opts.labelRm) == 0 {
return errors.New("no changes specified, set at least one of the flags: " +
"--cpu, --memory, --memory-reservation, --label-add, --label-rm")
}
labelAdd, err := parseLabels(opts.labelAdd)
if err != nil {
return err
}
if err = api.ValidateLabels(labelAdd); err != nil {
return fmt.Errorf("invalid service labels: %w", err)
}
for _, key := range opts.labelRm {
if _, ok := labelAdd[key]; ok {
return fmt.Errorf("label '%s' cannot be both added and removed", key)
}
}
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 {
return fmt.Errorf("inspect service '%s': %w", opts.service, err)
}
// Check all containers before changing any of them to avoid a partially updated service when a limit can't be
// changed in place. Containers may have different specs after a failed deployment so each one is updated from
// its own spec.
updates := make(map[string]api.UpdateContainerOptions, len(svc.Containers))
for _, mc := range svc.Containers {
spec := applyServiceUpdate(mc.Container.ServiceSpec, opts, labelAdd)
resources := mc.Container.AppliedResources()
if err = resources.ValidateUpdateInPlace(spec.Container.Resources); err != nil {
return fmt.Errorf("container '%s' on machine '%s' can't be updated without recreating it: %w",
stringid.TruncateID(mc.Container.ID), mc.MachineName, err)
}
containerOpts := deploy.ContainerUpdateOptions(mc.Container, spec)
if containerOpts != (api.UpdateContainerOptions{}) {
updates[mc.Container.ID] = containerOpts
}
}
if len(updates) == 0 {
fmt.Printf("Service %s is already up to date. No changes required.\n", tui.NameStyle.Render(svc.Name))
return nil
}
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
// Keep updating the remaining containers if one fails, for example, because its machine is down.
// Running the command again only updates the containers that still differ.
var errs []error
for id, containerOpts := range updates {
if err := clusterClient.UpdateContainer(ctx, svc.ID, id, containerOpts); err != nil {
errs = append(errs, fmt.Errorf("update container '%s': %w", stringid.TruncateID(id), err))
}
}
return errors.Join(errs...)
}, uncli.ProgressOut(), "Updating service "+tui.NameStyle.Render(svc.Name))
}
// applyServiceUpdate returns a copy of the spec with the requested changes applied.
func applyServiceUpdate(spec api.ServiceSpec, opts updateOptions, labelAdd map[string]string) api.ServiceSpec {
updated := spec.Clone()
if opts.cpuChanged {
updated.Container.Resources.CPU = opts.cpu.Value()
}
if opts.memoryChanged {
updated.Container.Resources.Memory = opts.memory.Value()
}
if opts.reservationChanged {
updated.Container.Resources.MemoryReservation = opts.memoryReservation.Value()
}
if len(labelAdd) > 0 && updated.Labels == nil {
updated.Labels = make(map[string]string, len(labelAdd))
}
maps.Copy(updated.Labels, labelAdd)
for key := range updated.Labels {
if slices.Contains(opts.labelRm, key) {
delete(updated.Labels, key)
}
}
return updated
}
-106
View File
@@ -1,106 +0,0 @@
package service
import (
"testing"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyServiceUpdate(t *testing.T) {
t.Parallel()
newSpec := func() api.ServiceSpec {
return api.ServiceSpec{
Name: "web",
Container: api.ContainerSpec{
Image: "nginx",
Labels: map[string]string{"container": "keep"},
Resources: api.ContainerResources{
CPU: 100 * api.MilliCore,
Memory: 1024,
MemoryReservation: 512,
},
},
Labels: map[string]string{"tier": "frontend", "legacy": "true"},
}
}
t.Run("adds, changes and removes labels", func(t *testing.T) {
t.Parallel()
spec := newSpec()
updated := applyServiceUpdate(spec, updateOptions{labelRm: []string{"legacy", "missing"}},
map[string]string{"tier": "backend", "team": "core"})
assert.Equal(t, map[string]string{"tier": "backend", "team": "core"}, updated.Labels)
assert.Equal(t, map[string]string{"container": "keep"}, updated.Container.Labels)
assert.Equal(t, spec.Container.Resources, updated.Container.Resources)
assert.Equal(t, map[string]string{"tier": "frontend", "legacy": "true"}, spec.Labels,
"Expected the original spec to stay unchanged")
})
t.Run("adds labels to a spec without labels", func(t *testing.T) {
t.Parallel()
spec := newSpec()
spec.Labels = nil
updated := applyServiceUpdate(spec, updateOptions{}, map[string]string{"tier": "backend"})
assert.Equal(t, map[string]string{"tier": "backend"}, updated.Labels)
})
t.Run("changes only requested limits", func(t *testing.T) {
t.Parallel()
opts := updateOptions{cpuChanged: true}
require.NoError(t, opts.cpu.Set("0.5"))
updated := applyServiceUpdate(newSpec(), opts, nil)
assert.EqualValues(t, 500*api.MilliCore, updated.Container.Resources.CPU)
assert.EqualValues(t, 1024, updated.Container.Resources.Memory)
assert.EqualValues(t, 512, updated.Container.Resources.MemoryReservation)
})
t.Run("sends only changed fields to the container", func(t *testing.T) {
t.Parallel()
opts := updateOptions{memoryChanged: true}
require.NoError(t, opts.memory.Set("2048"))
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(ctr.ServiceSpec, opts, nil))
require.NotNil(t, containerOpts.Memory)
assert.EqualValues(t, 2048, *containerOpts.Memory)
assert.Nil(t, containerOpts.CPU)
assert.Nil(t, containerOpts.MemoryReservation)
assert.Nil(t, containerOpts.ServiceLabels)
})
t.Run("removing the last label clears service labels", func(t *testing.T) {
t.Parallel()
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(
ctr.ServiceSpec, updateOptions{labelRm: []string{"tier", "legacy"}}, nil,
))
require.NotNil(t, containerOpts.ServiceLabels)
assert.Empty(t, *containerOpts.ServiceLabels)
})
t.Run("no changes produce empty container options", func(t *testing.T) {
t.Parallel()
ctr := api.ServiceContainer{ServiceSpec: newSpec()}
containerOpts := deploy.ContainerUpdateOptions(ctr, applyServiceUpdate(
ctr.ServiceSpec, updateOptions{}, map[string]string{"tier": "frontend"},
))
assert.Equal(t, api.UpdateContainerOptions{}, containerOpts)
})
}
-82
View File
@@ -1,82 +0,0 @@
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
}
+3 -4
View File
@@ -10,10 +10,9 @@ import (
func NewRemoveCommand() *cobra.Command { func NewRemoveCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "rm [NAME]", Use: "rm [NAME]",
Short: "Remove a cluster.", Short: "Remove a cluster.",
Aliases: []string{"remove", "delete"}, Args: cobra.MaximumNArgs(1),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
p := cmd.Context().Value("provisioner").(*ucind.Provisioner) p := cmd.Context().Value("provisioner").(*ucind.Provisioner)
@@ -3,18 +3,17 @@ package caddy
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"charm.land/lipgloss/v2"
"github.com/alecthomas/chroma/v2/quick" "github.com/alecthomas/chroma/v2/quick"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type configOptions struct { type configOptions struct {
machine string machine string
noColor bool
} }
func NewConfigCommand() *cobra.Command { func NewConfigCommand() *cobra.Command {
@@ -32,6 +31,8 @@ func NewConfigCommand() *cobra.Command {
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "", cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to get the configuration from. (default is connected machine)") "Name or ID of the machine to get the configuration from. (default is connected machine)")
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
"Disable syntax highlighting for the output.")
completion.MachinesFlag(cmd) completion.MachinesFlag(cmd)
@@ -45,20 +46,27 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
} }
defer clusterClient.Close() defer clusterClient.Close()
config, err := clusterClient.Caddy.Config(ctx, client.CaddyConfigOptions{Machine: opts.machine}) 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
}
}
config, err := clusterClient.Caddy.GetConfig(ctx, nil)
if err != nil { if err != nil {
return fmt.Errorf("get Caddy config: %w", err) return fmt.Errorf("get Caddy config: %w", err)
} }
// Print the Caddyfile with syntax highlighting. // Print the Caddyfile with syntax highlighting.
if err = quick.Highlight(lipgloss.Writer, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil { if opts.noColor {
// If highlighting fails, fall back to plain output. fmt.Print(config.Caddyfile)
lipgloss.Print(config.Caddyfile) } else {
} if err = quick.Highlight(os.Stdout, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
// If highlighting fails, fall back to plain output.
if config.LastReconciliationError != "" { fmt.Print(config.Caddyfile)
tui.PrintWarning(fmt.Sprintf("last Caddy config load failed: %s\nShowing the last saved Caddyfile.", }
config.LastReconciliationError))
} }
return nil return nil
+242
View File
@@ -0,0 +1,242 @@
package caddy
import (
"context"
"errors"
"fmt"
"maps"
"os"
"slices"
"strings"
"charm.land/lipgloss/v2"
"github.com/docker/cli/cli/streams"
"github.com/docker/compose/v2/pkg/progress"
"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/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra"
)
type deployOptions struct {
caddyfile string
image string
machines []string
}
func NewDeployCommand() *cobra.Command {
opts := deployOptions{}
cmd := &cobra.Command{
Use: "deploy",
Short: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.",
Long: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.\n" +
"A rolling update is performed when updating existing containers to minimise disruption.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return runDeploy(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
"Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.")
cmd.Flags().StringVar(&opts.image, "image", "",
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
"list. (default is all machines)")
completion.MachinesFlag(cmd)
return cmd
}
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
caddyfile := ""
if opts.caddyfile != "" {
data, err := os.ReadFile(opts.caddyfile)
if err != nil {
return fmt.Errorf("read Caddyfile: %w", err)
}
caddyfile = strings.TrimSpace(string(data))
}
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
if err != nil {
if !errors.Is(err, api.ErrNotFound) {
return fmt.Errorf("inspect caddy service: %w", err)
}
fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(client.CaddyServiceName) +
tui.Faint.Render(" (not running)"))
} else {
fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(svc.Name) +
tui.Faint.Render(" ("+svc.Mode+" mode)"))
// Collect unique images of all containers in the running caddy service.
images := make(map[string]struct{}, len(svc.Containers))
for _, c := range svc.Containers {
images[c.Container.Config.Image] = struct{}{}
}
currentImages := slices.Collect(maps.Keys(images))
if len(currentImages) > 1 {
formattedImages := make([]string, len(currentImages))
for i, img := range currentImages {
formattedImages[i] = tui.FormatImage(img, tui.NoStyle)
}
fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") +
strings.Join(formattedImages, tui.Faint.Render(", ")))
} else {
fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], tui.NoStyle))
}
}
if opts.image != "" {
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green))
}
fmt.Println()
fmt.Println("Preparing a deployment plan...")
placement := api.Placement{
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
}
d, err := clusterClient.NewCaddyDeployment(opts.image, caddyfile, placement)
if err != nil {
return fmt.Errorf("create caddy deployment: %w", err)
}
if opts.image == "" {
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(d.Spec.Container.Image,
tui.Green) + tui.Faint.Render(" (latest stable)"))
}
plan, err := d.Plan(ctx)
if err != nil {
return fmt.Errorf("plan caddy deployment: %w", err)
}
if len(plan.Operations) == 0 {
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
} else {
if svc.ID == "" {
if len(opts.machines) > 0 {
fmt.Println("This will run a Caddy container on each selected machine.")
} else {
fmt.Println("This will run a Caddy container on each machine.")
}
}
fmt.Println()
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
fmt.Println()
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
deployTarget := ""
if directConn != "" {
deployTarget = directConn
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
fmt.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
deployTarget = contextName
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
fmt.Println()
}
fmt.Println(plan.Format())
summary := plan.FormatSummary()
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
fmt.Println()
title := "Proceed with deployment?"
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
// deploying to the wrong cluster.
if deployTarget != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with deployment to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
}
confirmed, err := tui.Confirm(title)
if err != nil {
return fmt.Errorf("confirm deployment: %w", err)
}
if !confirmed {
return cli.Cancelled("Caddy deploy cancelled. No changes were made.")
}
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if _, err = d.Run(ctx); err != nil {
return fmt.Errorf("deploy caddy: %w", err)
}
return nil
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode))
if err != nil {
return err
}
}
fmt.Println()
return UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
}
func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
domain, err := clusterClient.GetDomain(ctx)
if err != nil {
if errors.Is(err, api.ErrNotFound) {
fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').")
return nil
}
return fmt.Errorf("get cluster domain: %w", err)
}
fmt.Println("Updating cluster domain records in Uncloud DNS to point to machines running caddy service...")
// TODO: split the method into two: one to get the records and one to update them to ask for update confirmation.
var records []*pb.DNSRecord
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
var err error
records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName)
return err
}, progressOut, "Verifying internet access to caddy service")
if err != nil {
if errors.Is(err, client.ErrNoReachableMachines) {
fmt.Println()
fmt.Printf("DNS records for domain '%s' could not be updated as there are no internet-reachable "+
"machines running caddy containers.\n", domain)
fmt.Println()
fmt.Println("Possible solutions:")
fmt.Println("- Ensure your machines have public IP addresses")
fmt.Println("- Use --public-ip flag when adding machines to override the automatically detected IPs")
fmt.Println("- Check firewall settings on your machines")
fmt.Println("- Configure port forwarding if behind NAT")
fmt.Println("- Retry Caddy deployment with 'uc caddy deploy' after resolving connectivity issues")
fmt.Println()
fmt.Println("Your services won't be accessible from the internet until at least one machine " +
"becomes reachable. If you aren't planning to expose any services publicly, you can release " +
"the domain by running 'uc dns release'.")
}
return fmt.Errorf("failed to update DNS records pointing to caddy service: %w", err)
}
fmt.Println()
fmt.Println("DNS records updated to use only the internet-reachable machines running caddy service:")
for _, r := range records {
fmt.Printf(" %s %s → %s\n", r.Name, r.Type, strings.Join(r.Values, ", "))
}
return nil
}
@@ -1,7 +1,6 @@
package caddy package caddy
import ( import (
"github.com/psviderski/uncloud/cmd/uc/caddy/cert"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -11,10 +10,8 @@ func NewRootCommand() *cobra.Command {
Short: "Manage Caddy reverse proxy service.", Short: "Manage Caddy reverse proxy service.",
} }
cmd.AddCommand( cmd.AddCommand(
cert.NewRootCommand(),
NewConfigCommand(), NewConfigCommand(),
NewDeployCommand(), NewDeployCommand(),
NewLogsCommand(),
) )
return cmd return cmd
} }
@@ -14,7 +14,6 @@ func NewConnectionCommand() *cobra.Command {
Use: "connection", Use: "connection",
Aliases: []string{"conn"}, Aliases: []string{"conn"},
Short: "Choose a new default connection for the current context.", Short: "Choose a new default connection for the current context.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return selectConnection(uncli) return selectConnection(uncli)
@@ -5,7 +5,6 @@ import (
"maps" "maps"
"slices" "slices"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -16,7 +15,6 @@ func NewListCommand() *cobra.Command {
Use: "ls", Use: "ls",
Aliases: []string{"list"}, Aliases: []string{"list"},
Short: "List available cluster contexts.", Short: "List available cluster contexts.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(uncli) return list(uncli)
@@ -51,6 +49,6 @@ func list(uncli *cli.CLI) error {
t.Row(name, current, fmt.Sprintf("%d", connCount)) t.Row(name, current, fmt.Sprintf("%d", connCount))
} }
lipgloss.Println(t) fmt.Println(t)
return nil return nil
} }
@@ -10,7 +10,6 @@ func NewRootCommand() *cobra.Command {
Use: "ctx", Use: "ctx",
Aliases: []string{"context"}, Aliases: []string{"context"},
Short: "Switch between different cluster contexts. Contains subcommands to manage contexts.", Short: "Switch between different cluster contexts. Contains subcommands to manage contexts.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return selectContext(uncli) return selectContext(uncli)
@@ -11,7 +11,6 @@ func NewShowCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "show", Use: "show",
Short: "Show current cluster context.", Short: "Show current cluster context.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return show(uncli) return show(uncli)
@@ -22,6 +21,15 @@ func NewShowCommand() *cobra.Command {
} }
func show(uncli *cli.CLI) error { func show(uncli *cli.CLI) error {
fmt.Println(uncli.ContextOverrideOrCurrent()) // discard errors, only show the current context, otherwise nothing
if uncli.Config == nil {
return nil
}
if len(uncli.Config.Contexts) == 0 {
return nil
}
fmt.Println(uncli.Config.CurrentContext)
return nil return nil
} }
@@ -8,7 +8,6 @@ import (
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -18,7 +17,6 @@ func NewUseCommand() *cobra.Command {
Short: "Switch to a different cluster context.", Short: "Switch to a different cluster context.",
Long: "Switch to a different cluster context. If no context is provided, " + Long: "Switch to a different cluster context. If no context is provided, " +
"a list of available contexts will be displayed for selection.", "a list of available contexts will be displayed for selection.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
@@ -51,10 +49,6 @@ func selectContext(uncli *cli.CLI) error {
if len(uncli.Config.Contexts) == 0 { if len(uncli.Config.Contexts) == 0 {
return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path()) 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)) contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
+3 -11
View File
@@ -18,7 +18,7 @@ import (
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose" "github.com/psviderski/uncloud/pkg/client/compose"
"github.com/psviderski/uncloud/pkg/client/deploy" "github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/operation" "github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -163,14 +163,6 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
fmt.Println() 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{ strategy := &deploy.RollingStrategy{
ForceRecreate: opts.recreate, ForceRecreate: opts.recreate,
SkipHealthMonitor: opts.skipHealth, SkipHealthMonitor: opts.skipHealth,
@@ -211,7 +203,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. // Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
if !opts.yes { if !opts.yes {
if !tui.IsTerminalAvailable() { if !tui.IsStdinTerminal() {
return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " + 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") "use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
} }
@@ -305,7 +297,7 @@ const defaultFailedContainerLogsTail = 10
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid. // UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
func failedContainerLogsTail() int { func failedContainerLogsTail() int {
if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" { if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" {
if tail, err := logs.ParseTail(v); err == nil && (tail == -1 || tail > 0) { if tail, err := logs.Tail(v); err == nil && (tail == -1 || tail > 0) {
return tail return tail
} }
} }
@@ -37,7 +37,6 @@ func release(ctx context.Context, uncli *cli.CLI) error {
if status.Convert(err).Code() == codes.NotFound { if status.Convert(err).Code() == codes.NotFound {
return errors.New("no domain reserved") return errors.New("no domain reserved")
} }
return fmt.Errorf("release cluster domain: %w", err)
} }
fmt.Printf("Released cluster domain: %s\n", domain.Name) fmt.Printf("Released cluster domain: %s\n", domain.Name)
@@ -5,9 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/psviderski/uncloud/api/pb" "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"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -49,8 +49,7 @@ func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
domain, err := clusterClient.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.endpoint}) domain, err := clusterClient.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.endpoint})
if err != nil { if err != nil {
if status.Convert(err).Code() == codes.AlreadyExists { if status.Convert(err).Code() == codes.AlreadyExists {
return errors.New("cluster domain already configured, unset it with 'uc dns set \"\"' " + return errors.New("domain already reserved")
"or release a reservation with 'uc dns release' first")
} }
return err return err
} }
@@ -7,19 +7,16 @@ import (
func NewRootCommand() *cobra.Command { func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "dns", Use: "dns",
Short: "Manage the cluster domain.", Short: "Manage cluster domain in Uncloud DNS.",
Long: "Manage the cluster domain.\n" + Long: "Manage cluster domain in Uncloud DNS.\n" +
"DNS commands allow you to reserve or release a unique 'xxxxxx.uncld.dev' domain for your " + "DNS commands allow you to reserve or release a unique 'xxxxxx.uncld.dev' domain for your " +
"cluster. When reserved, Caddy service deployments will automatically update DNS records to route " + "cluster. When reserved, Caddy service deployments will automatically update DNS records to route " +
"traffic to the services in the cluster.\n\n" + "traffic to the services in the cluster.",
"EXPERIMENTAL: Use 'uc dns set' to configure an externally managed domain instead. " +
"Uncloud does not manage DNS records for manually set domains.",
} }
cmd.AddCommand( cmd.AddCommand(
NewReleaseCommand(), NewReleaseCommand(),
NewReserveCommand(), NewReserveCommand(),
NewShowCommand(), NewShowCommand(),
NewSetCommand(),
) )
return cmd return cmd
} }
@@ -33,11 +33,11 @@ func show(ctx context.Context, uncli *cli.CLI) error {
domain, err := clusterClient.GetDomain(ctx) domain, err := clusterClient.GetDomain(ctx)
if err != nil { if err != nil {
if errors.Is(err, api.ErrNotFound) { if errors.Is(err, api.ErrNotFound) {
return errors.New("no cluster domain configured") return errors.New("no domain reserved")
} }
return err return err
} }
fmt.Println(domain.Name) fmt.Println(domain)
return nil return nil
} }
+65 -61
View File
@@ -13,7 +13,6 @@ import (
"github.com/charmbracelet/colorprofile" "github.com/charmbracelet/colorprofile"
"github.com/containerd/platforms" "github.com/containerd/platforms"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -89,16 +88,24 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
} }
defer clusterClient.Close() 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) machines := cli.ExpandCommaSeparatedValues(opts.machines)
var clusterImages []api.MachineImages clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
err = tui.RunSpinner(ctx, "Listing images...", func(ctx context.Context) error { Machines: machines,
var err error Name: opts.nameFilter,
clusterImages, err = clusterClient.ListImages(ctx, api.ImageFilter{
Machines: machines,
Name: opts.nameFilter,
})
return err
}) })
if err != nil { if err != nil {
return fmt.Errorf("list images: %w", err) return fmt.Errorf("list images: %w", err)
@@ -108,21 +115,60 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
var rows []imageRow var rows []imageRow
for _, machineImages := range clusterImages { for _, machineImages := range clusterImages {
if err := machineImages.Metadata.Err(); 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. // Get machine name for better readability.
machineName := machineImages.Metadata.MachineName machineName := machineImages.Metadata.Machine
if m := allMachines.FindByNameOrID(machineName); m != nil {
machineName = m.Machine.Name
}
store := "docker" store := "docker"
if machineImages.ContainerdStore { if machineImages.ContainerdStore {
store = "containerd" store = "containerd"
} }
rows = append(rows, imageRows(machineImages.Images, machineName, store)...) // Process each image for this machine.
for _, img := range machineImages.Images {
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
name := "<none>"
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
name = img.RepoTags[0]
}
imgPlatforms, _ := imagePlatforms(img)
formattedPlatforms := formatPlatforms(imgPlatforms)
created := ""
createdAt := time.Unix(img.Created, 0)
if !createdAt.IsZero() {
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
}
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
inUse := "-"
if img.Containers != -1 { // -1 means the info is not available.
if img.Containers > 0 {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
} else {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
}
}
rows = append(rows, imageRow{
id: id,
name: name,
platforms: formattedPlatforms,
createdHuman: created,
createdUnix: img.Created,
size: size,
inUse: inUse,
store: store,
machine: machineName,
})
}
} }
if len(rows) == 0 { if len(rows) == 0 {
@@ -143,60 +189,18 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
}) })
// Print the images in a table format. // Print the images in a table format.
lipgloss.Println(formatImageTable(rows)) fmt.Println(formatImageTable(rows))
return nil return nil
} }
// imageRows keeps every stored name visible and retains digest-only and unnamed images.
func imageRows(images []image.Summary, machineName, store string) []imageRow {
var rows []imageRow
for _, img := range images {
// RepoTags includes stored digest names on containerd. RepoDigests can include derived names.
imageNames := img.RepoTags
if len(imageNames) == 0 {
imageNames = []string{"<untagged>"}
}
imgPlatforms, _ := imagePlatforms(img)
created := ""
if img.Created > 0 {
created = units.HumanDuration(time.Since(time.Unix(img.Created, 0))) + " ago"
}
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
inUse := "-"
if img.Containers >= 0 { // -1 means the info is not available.
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
if img.Containers > 0 {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
}
}
for _, name := range imageNames {
rows = append(rows, imageRow{
id: stringid.TruncateID(img.ID),
name: name,
platforms: formatPlatforms(imgPlatforms),
createdHuman: created,
createdUnix: img.Created,
size: units.HumanSizeWithPrecision(float64(img.Size), 3),
inUse: inUse,
store: store,
machine: machineName,
})
}
}
return rows
}
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform. // imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
func imagePlatforms(img image.Summary) ([]string, bool) { func imagePlatforms(img image.Summary) ([]string, bool) {
var formattedPlatforms []string var formattedPlatforms []string
multiPlatform := false multiPlatform := false
for _, m := range img.Manifests { for _, m := range img.Manifests {
if m.Kind != image.ManifestKindImage || !m.Available || m.ImageData == nil { if m.Kind != image.ManifestKindImage || !m.Available {
continue continue
} }
@@ -13,7 +13,6 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand( cmd.AddCommand(
NewListCommand(), NewListCommand(),
NewPushCommand(), NewPushCommand(),
NewRemoveCommand(),
) )
return cmd return cmd
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import ( import (
"strings" "strings"
"github.com/psviderski/uncloud/cmd/uc/image" "github.com/psviderski/uncloud/cmd/uncloud/image"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -8,9 +8,10 @@ import (
"strings" "strings"
"time" "time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uc/caddy" "github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
@@ -28,8 +29,6 @@ type addOptions struct {
sshKey string sshKey string
version string version string
wgEndpoints []string wgEndpoints []string
wgPort int
wgMTU int
yes bool yes bool
} }
@@ -40,19 +39,13 @@ func NewAddCommand() *cobra.Command {
Short: "Add a remote machine to a cluster.", Short: "Add a remote machine to a cluster.",
Long: `Add a new machine to an existing Uncloud 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: Connection methods:
[ssh://]user@host - Use system 'ssh' command with full SSH config support (default, no prefix required) [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`, ssh+go://user@host - Use Go's built-in SSH library`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
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 { RunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
// Determine connection mode and strip scheme. // Determine connection mode and strip scheme.
@@ -77,15 +70,14 @@ Connection methods:
return add(cmd.Context(), uncli, remoteMachine, opts) return add(cmd.Context(), uncli, remoteMachine, opts)
}, },
} }
cmd.Flags().StringVarP(&opts.name, "name", "n", "", 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().BoolVar( cmd.Flags().BoolVar(
&opts.noCaddy, "no-caddy", false, &opts.noCaddy, "no-caddy", false,
"Don't deploy Caddy reverse proxy service to the machine.", "Don't deploy Caddy reverse proxy service to the machine.",
) )
cmd.Flags().BoolVar( cmd.Flags().BoolVar(
&opts.noInstall, "no-install", false, &opts.noInstall, "no-install", false,
"Skip installation of Docker and the Uncloud daemon on the machine. "+ "Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
"Assumes they're already installed and running.", "Assumes they're already installed and running.",
) )
cmd.Flags().StringVar( cmd.Flags().StringVar(
@@ -100,25 +92,17 @@ Connection methods:
) )
cmd.Flags().StringVar( cmd.Flags().StringVar(
&opts.version, "version", "latest", &opts.version, "version", "latest",
"Version of the Uncloud daemon to install on the machine. [$UNCLOUD_DAEMON_VERSION]", "Version of the Uncloud daemon to install on the machine.",
) )
cmd.Flags().StringSliceVar( cmd.Flags().StringSliceVar(
&opts.wgEndpoints, "wg-endpoint", nil, &opts.wgEndpoints, "wg-endpoint", nil,
"WireGuard endpoint address that other machines in the cluster should use to establish "+ fmt.Sprintf("WireGuard endpoint address that other machines in the cluster should use to establish "+
"WireGuard connections\n"+ "WireGuard connections\n"+
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\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 the value of --wg-port if omitted.\n"+ "Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is %d if omitted.\n", network.WireGuardPort)+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\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.", "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, cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+ "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]") "Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
@@ -141,26 +125,17 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
publicIP = &ip 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{ addOpts := cli.AddMachineOptions{
MachineName: opts.name, MachineName: opts.name,
PublicIP: publicIP, PublicIP: publicIP,
RemoteMachine: remoteMachine, RemoteMachine: remoteMachine,
SkipInstall: opts.noInstall, SkipInstall: opts.noInstall,
Version: opts.version, Version: opts.version,
WireguardMTU: opts.wgMTU,
WireguardPort: opts.wgPort,
AutoConfirm: opts.yes, AutoConfirm: opts.yes,
} }
if len(opts.wgEndpoints) > 0 { if len(opts.wgEndpoints) > 0 {
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints) expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
endpoints, err := cli.ParseWireGuardEndpoints(expanded, uint16(opts.wgPort)) endpoints, err := cli.ParseWireGuardEndpoints(expanded)
if err != nil { if err != nil {
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err) return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
} }
@@ -179,20 +154,32 @@ 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. // Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service.
err = tui.RunSpinner(ctx, "Waiting for the machine to join the cluster...", func(ctx context.Context) error { err = spinner.New().
return machineClient.WaitClusterReady(ctx, 5*time.Minute) 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()
if err != nil { if err != nil {
return fmt.Errorf("wait for machine to join the cluster: %w", err) return fmt.Errorf("wait for machine to join the cluster: %w", err)
} }
fmt.Println("Machine joined the cluster.") fmt.Println("Machine joined the cluster.")
// Deploy the current caddy service spec to run a container on the added machine. This keeps the service // TODO: scale the existing Caddy service to the new machine instead of running a new deployment
// configuration however it was deployed and shouldn't change the containers on other machines if specs on them // that may cause a small downtime.
// haven't diverged. // Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
// use the deployed image version.
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have // NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
// issues accessing the API of existing machines in the cluster. // issues accessing the Machine API of existing machines in the cluster.
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65. // See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
caddyImage := ""
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName) caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
if err != nil { if err != nil {
if errors.Is(err, api.ErrNotFound) { if errors.Is(err, api.ErrNotFound) {
@@ -201,10 +188,26 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
} }
return fmt.Errorf("inspect caddy service: %w", err) return fmt.Errorf("inspect caddy service: %w", err)
} }
caddyImage = caddySvc.Containers[0].Container.Config.Image
// Find the latest created container and use its image.
var latestCreated time.Time
for _, c := range caddySvc.Containers[1:] {
created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
if err != nil {
continue
}
if created.After(latestCreated) {
latestCreated = created
caddyImage = c.Container.Config.Image
}
}
fmt.Println() fmt.Println()
fmt.Println("Preparing Caddy deployment...") fmt.Println("Preparing Caddy deployment...")
d := clusterClient.NewDeployment(caddySvc.Spec(), nil) d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
if err != nil {
return fmt.Errorf("create caddy deployment: %w", err)
}
plan, err := d.Plan(ctx) plan, err := d.Plan(ctx)
if err != nil { if err != nil {
@@ -7,16 +7,17 @@ import (
"strings" "strings"
"time" "time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/api/pb" "github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uc/caddy" "github.com/psviderski/uncloud/cmd/uncloud/dns"
"github.com/psviderski/uncloud/cmd/uc/dns"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/cluster" "github.com/psviderski/uncloud/internal/machine/cluster"
"github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -32,8 +33,6 @@ type initOptions struct {
sshKey string sshKey string
version string version string
wgEndpoints []string wgEndpoints []string
wgPort int
wgMTU int
yes bool yes bool
} }
@@ -45,9 +44,6 @@ func NewInitCommand() *cobra.Command {
Long: `Initialise a new cluster by setting up a remote machine as the first member. 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. 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: Connection methods:
[ssh://]user@host - Use system 'ssh' command with full SSH config support (default, no prefix required) [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`, ssh+go://user@host - Use Go's built-in SSH library`,
@@ -65,12 +61,9 @@ Connection methods:
uc machine init root@<your-server-ip> --no-caddy --no-dns`, uc machine init root@<your-server-ip> --no-caddy --no-dns`,
// TODO: support initialising a cluster on the local machine. // TODO: support initialising a cluster on the local machine.
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
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 { RunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
var remoteMachine *cli.RemoteMachine var remoteMachine *cli.RemoteMachine
@@ -107,7 +100,7 @@ Connection methods:
"API endpoint for the Uncloud DNS service.") "API endpoint for the Uncloud DNS service.")
cmd.Flags().StringVarP( cmd.Flags().StringVarP(
&opts.name, "name", "n", "", &opts.name, "name", "n", "",
"Assign a name to the machine. (default is the machine's hostname)", "Assign a name to the machine.",
) )
cmd.Flags().StringVar( cmd.Flags().StringVar(
&opts.network, "network", cluster.DefaultNetwork.String(), &opts.network, "network", cluster.DefaultNetwork.String(),
@@ -123,7 +116,7 @@ Connection methods:
) )
cmd.Flags().BoolVar( cmd.Flags().BoolVar(
&opts.noInstall, "no-install", false, &opts.noInstall, "no-install", false,
"Skip installation of Docker and the Uncloud daemon on the machine. "+ "Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
"Assumes they're already installed and running.", "Assumes they're already installed and running.",
) )
cmd.Flags().StringVar( cmd.Flags().StringVar(
@@ -138,25 +131,17 @@ Connection methods:
) )
cmd.Flags().StringVar( cmd.Flags().StringVar(
&opts.version, "version", "latest", &opts.version, "version", "latest",
"Version of the Uncloud daemon to install on the machine. [$UNCLOUD_DAEMON_VERSION]", "Version of the Uncloud daemon to install on the machine.",
) )
cmd.Flags().StringSliceVar( cmd.Flags().StringSliceVar(
&opts.wgEndpoints, "wg-endpoint", nil, &opts.wgEndpoints, "wg-endpoint", nil,
"WireGuard endpoint address that other machines in the cluster should use to establish "+ fmt.Sprintf("WireGuard endpoint address that other machines in the cluster should use to establish "+
"WireGuard connections\n"+ "WireGuard connections\n"+
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\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 the value of --wg-port if omitted.\n"+ "Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is %d if omitted.\n", network.WireGuardPort)+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\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.", "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, cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+ "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]") "Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
@@ -193,13 +178,6 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
} }
publicIP = &ip 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{ initOpts := cli.InitClusterOptions{
Context: opts.context, Context: opts.context,
MachineName: opts.name, MachineName: opts.name,
@@ -208,31 +186,39 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
RemoteMachine: remoteMachine, RemoteMachine: remoteMachine,
SkipInstall: opts.noInstall, SkipInstall: opts.noInstall,
Version: opts.version, Version: opts.version,
WireguardMTU: opts.wgMTU,
WireguardPort: opts.wgPort,
AutoConfirm: opts.yes, AutoConfirm: opts.yes,
} }
if len(opts.wgEndpoints) > 0 { if len(opts.wgEndpoints) > 0 {
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints) expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
endpoints, err := cli.ParseWireGuardEndpoints(expanded, uint16(opts.wgPort)) endpoints, err := cli.ParseWireGuardEndpoints(expanded)
if err != nil { if err != nil {
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err) return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
} }
initOpts.WireguardEndpoints = endpoints initOpts.WireguardEndpoints = endpoints
} }
clusterClient, err := uncli.InitCluster(ctx, initOpts) client, err := uncli.InitCluster(ctx, initOpts)
if err != nil { if err != nil {
return err return err
} }
defer clusterClient.Close() defer client.Close()
// Since the cluster API needs a few moments to become ready after cluster initialisation, // 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 // 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. // as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error { err = spinner.New().
return clusterClient.WaitClusterReady(ctx, 1*time.Minute) 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()
if err != nil { if err != nil {
return fmt.Errorf("wait for cluster to be ready: %w", err) return fmt.Errorf("wait for cluster to be ready: %w", err)
} }
@@ -245,7 +231,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
fmt.Println() fmt.Println()
if !opts.noDNS { if !opts.noDNS {
domain, err := clusterClient.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint}) domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
if err != nil { if err != nil {
return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err) return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err)
} }
@@ -253,7 +239,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
} }
if !opts.noCaddy { if !opts.noCaddy {
d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{}) d, err := client.NewCaddyDeployment("", "", api.Placement{})
if err != nil { if err != nil {
return fmt.Errorf("create caddy deployment: %w", err) return fmt.Errorf("create caddy deployment: %w", err)
} }
@@ -269,7 +255,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
} }
fmt.Println() fmt.Println()
return caddy.UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut()) return caddy.UpdateDomainRecords(ctx, client, uncli.ProgressOut())
} }
return nil return nil
@@ -3,13 +3,10 @@ package machine
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"strings"
"time"
"github.com/psviderski/uncloud/internal/cli" "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/cli/logs"
"github.com/psviderski/uncloud/internal/journal"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -21,14 +18,14 @@ func NewLogsCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "logs [SERVICE...]", Use: "logs [SERVICE...]",
Aliases: []string{"log"}, Aliases: []string{"log"},
Short: "View system service logs.", Short: "View systemd service logs.",
Long: `View logs from the specified system service(s) across all machines in the cluster. Long: `View logs from the specified systemd service(s) across all machines in the cluster.
Use -m to restrict to specific machines. Use -m to restrict to specific machines.
Supported services: Supported services:
corrosion the Corrosion distributed state store uncloud the Uncloud daemon
docker the Docker daemon docker the Docker daemon
uncloud the Uncloud daemon uncloud-corrosion the Corrosion distributed state store
If no services are specified, streams logs from the uncloud service.`, If no services are specified, streams logs from the uncloud service.`,
Example: ` # View recent logs for the uncloud service. Example: ` # View recent logs for the uncloud service.
@@ -39,7 +36,7 @@ If no services are specified, streams logs from the uncloud service.`,
uc machine logs -f uncloud uc machine logs -f uncloud
# View logs from multiple services. # View logs from multiple services.
uc machine logs uncloud docker corrosion uc machine logs uncloud docker uncloud-corrosion
# Show last 20 lines per machine (default is 100). # Show last 20 lines per machine (default is 100).
uc machine logs -n 20 docker uc machine logs -n 20 docker
@@ -51,7 +48,7 @@ If no services are specified, streams logs from the uncloud service.`,
uc machine logs --since 3h --until 1h30m docker uc machine logs --since 3h --until 1h30m docker
# View logs only from specific machines. # View logs only from specific machines.
uc machine logs -m machine1,machine2 uncloud corrosion`, uc machine logs -m machine1,machine2 uncloud uncloud-corrosion`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return runLogs(cmd.Context(), uncli, args, options) return runLogs(cmd.Context(), uncli, args, options)
@@ -59,31 +56,25 @@ If no services are specified, streams logs from the uncloud service.`,
} }
cmd.Flags().AddFlagSet(logs.Flags(&options)) cmd.Flags().AddFlagSet(logs.Flags(&options))
completion.MachinesFlag(cmd)
return cmd return cmd
} }
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error { func runLogs(ctx context.Context, uncli *cli.CLI, units []string, opts logs.Options) error {
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now()) if len(units) == 0 {
if err != nil { units = []string{journal.UnitUncloud}
return err
}
tail, err := opts.TailLines()
if err != nil {
return err
} }
if len(services) == 0 { for _, unit := range units {
services = []string{api.SystemServiceUncloud} 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, ", "))
} }
} }
tail, err := logs.Tail(opts.Tail)
if err != nil {
return err
}
c, err := uncli.ConnectCluster(ctx) c, err := uncli.ConnectCluster(ctx)
if err != nil { if err != nil {
return fmt.Errorf("connect to cluster: %w", err) return fmt.Errorf("connect to cluster: %w", err)
@@ -93,8 +84,8 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
logsOpts := api.ServiceLogsOptions{ logsOpts := api.ServiceLogsOptions{
Follow: opts.Follow, Follow: opts.Follow,
Tail: tail, Tail: tail,
Since: since, Since: opts.Since,
Until: until, Until: opts.Until,
Machines: cli.ExpandCommaSeparatedValues(opts.Machines), Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
} }
@@ -110,27 +101,27 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
machineNames = append(machineNames, m.Machine.Name) machineNames = append(machineNames, m.Machine.Name)
} }
// Collect one log stream per service. MachineLogs merges across machines internally. // Collect one log stream per unit. MachineLogs merges across machines internally.
serviceStreams := make([]<-chan api.ServiceLogEntry, 0, len(services)) unitStreams := make([]<-chan api.ServiceLogEntry, 0, len(units))
for _, service := range services { for _, unit := range units {
ch, err := c.MachineLogs(ctx, service, logsOpts) ch, err := c.MachineLogs(ctx, unit, logsOpts)
if err != nil { if err != nil {
return fmt.Errorf("stream logs for system service '%s': %w", service, err) return fmt.Errorf("stream logs for systemd service '%s': %w", unit, err)
} }
serviceStreams = append(serviceStreams, ch) unitStreams = append(unitStreams, ch)
} }
var stream <-chan api.ServiceLogEntry var stream <-chan api.ServiceLogEntry
if len(serviceStreams) == 1 { if len(unitStreams) == 1 {
stream = serviceStreams[0] stream = unitStreams[0]
} else { } else {
// Each MachineLogs stream already runs its own inner merger with stall detection, // Each MachineLogs stream already runs its own inner merger with stall detection,
// so the outer merger across services skips it to avoid duplicate warnings. // so the outer merger across units skips it to avoid duplicate warnings.
merger := client.NewLogMerger(serviceStreams, client.LogMergerOptions{}) merger := client.NewLogMerger(unitStreams, client.LogMergerOptions{})
stream = merger.Stream() stream = merger.Stream()
} }
formatter := logs.NewFormatter(machineNames, services, opts.UTC) formatter := logs.NewFormatter(machineNames, units, opts.UTC)
// Print merged logs. // Print merged logs.
for entry := range stream { for entry := range stream {
@@ -2,12 +2,10 @@ package machine
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/netip" "net/netip"
"strings" "strings"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
@@ -15,29 +13,19 @@ import (
) )
func NewListCommand() *cobra.Command { func NewListCommand() *cobra.Command {
var output string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "ls", Use: "ls",
Aliases: []string{"list"}, Aliases: []string{"list"},
Short: "List machines in a cluster.", Short: "List machines in a cluster.",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, output) return list(cmd.Context(), uncli)
}, },
} }
cmd.Flags().StringVarP(&output, "output", "o", "",
"Output format: 'json' or empty for a human-readable table.")
return cmd return cmd
} }
func list(ctx context.Context, uncli *cli.CLI, output string) error { func list(ctx context.Context, uncli *cli.CLI) error {
if output != "" && output != "json" {
return fmt.Errorf("unsupported output format '%s' (supported: json)", output)
}
client, err := uncli.ConnectCluster(ctx) client, err := uncli.ConnectCluster(ctx)
if err != nil { if err != nil {
return fmt.Errorf("connect to cluster: %w", err) return fmt.Errorf("connect to cluster: %w", err)
@@ -49,19 +37,9 @@ func list(ctx context.Context, uncli *cli.CLI, output string) error {
return fmt.Errorf("list machines: %w", err) 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. // Print the list of machines in a table format.
t := tui.NewTable() t := tui.NewTable()
t.Headers("NAME", "STATE", "ADDRESS", "PUBLIC IP", "WIREGUARD ENDPOINTS", t.Headers("NAME", "STATE", "ADDRESS", "PUBLIC IP", "WIREGUARD ENDPOINTS", "MACHINE ID")
"OS", "KERNEL", "ARCH", "DOCKER", "VERSION")
for _, member := range machines { for _, member := range machines {
m := member.Machine m := member.Machine
@@ -80,46 +58,17 @@ func list(ctx context.Context, uncli *cli.CLI, output string) error {
endpoints[i] = addrPort.String() 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( t.Row(
m.Name, m.Name,
capitalise(member.State.String()), capitalise(member.State.String()),
subnet.String(), subnet.String(),
publicIP, publicIP,
strings.Join(endpoints, tui.Faint.Render(", ")), strings.Join(endpoints, tui.Faint.Render(", ")),
osName, member.Machine.Id,
kernel,
arch,
dockerVersion,
daemonVersion,
) )
} }
lipgloss.Println(t) fmt.Println(t)
return nil return nil
} }
@@ -13,10 +13,10 @@ import (
"charm.land/lipgloss/v2/tree" "charm.land/lipgloss/v2/tree"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -63,16 +63,15 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
} }
defer client.Close() defer client.Close()
// Verify the machine exists in the cluster. // Verify the machine exists and list all service containers on it including stopped ones.
member, err := client.InspectMachine(ctx, nameOrID) mctx, machines, err := client.ProxyMachinesContext(ctx, []string{nameOrID})
if err != nil { if err != nil {
return fmt.Errorf("inspect machine '%s': %w", nameOrID, err) return err
} }
m := member.Machine if len(machines) == 0 {
return fmt.Errorf("machine '%s' not found in the cluster", nameOrID)
// Create a proxy context for the machine being removed. }
// This is used for calls that need to run directly on that machine. m := machines[0].Machine
rmCtx := client.ProxySingleMachineContext(ctx, m.Id)
// Verify if the machine being removed is the proxy machine we're connected to. // Verify if the machine being removed is the proxy machine we're connected to.
proxyMachine, err := client.MachineClient.Inspect(ctx, nil) proxyMachine, err := client.MachineClient.Inspect(ctx, nil)
@@ -104,7 +103,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
if reset { if reset {
// Check if the machine is up and has service containers. // Check if the machine is up and has service containers.
listOpts := container.ListOptions{All: true} listOpts := container.ListOptions{All: true}
machineContainers, err := client.Docker.ListServiceContainers(rmCtx, "", listOpts) machineContainers, err := client.Docker.ListServiceContainers(mctx, "", listOpts)
if err == nil { if err == nil {
reachable = true reachable = true
containers = machineContainers[0].Containers containers = machineContainers[0].Containers
@@ -114,7 +113,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
plural = "s" plural = "s"
} }
fmt.Printf("Found %d service container%s on machine '%s':\n", len(containers), plural, m.Name) fmt.Printf("Found %d service container%s on machine '%s':\n", len(containers), plural, m.Name)
lipgloss.Println(formatContainerTree(containers)) fmt.Println(formatContainerTree(containers))
fmt.Println() fmt.Println()
fmt.Println("This will remove all service containers from the machine, remove it from the cluster, " + fmt.Println("This will remove all service containers from the machine, remove it from the cluster, " +
"and reset it to the uninitialised state.") "and reset it to the uninitialised state.")
@@ -136,7 +135,8 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
return fmt.Errorf("confirm removal: %w", err) return fmt.Errorf("confirm removal: %w", err)
} }
if !confirmed { if !confirmed {
return cli.Cancelled("Cancelled. Machine was not removed.") fmt.Println("Cancelled. Machine was not removed.")
return nil
} }
} }
@@ -150,22 +150,20 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
fmt.Println() fmt.Println()
} }
// 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(rmCtx, &pb.ResetRequest{})
if err != nil {
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 { if _, err = client.RemoveMachine(ctx, &pb.RemoveMachineRequest{Id: m.Id}); err != nil {
return fmt.Errorf("remove machine from cluster: %w", err) return fmt.Errorf("remove machine from cluster: %w", err)
} }
fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name) fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
if reset && reachable {
_, err = client.MachineClient.Reset(mctx, &pb.ResetRequest{})
if err != nil {
fmt.Printf("WARNING: Failed to reset machine: %v\n", err)
} else {
fmt.Println("Machine reset initiated and will complete in the background.")
}
}
// Remove the connection to the machine from the uncloud config if it exists. // Remove the connection to the machine from the uncloud config if it exists.
if uncli.Config != nil { if uncli.Config != nil {
contextName := uncli.ContextOverrideOrCurrent() contextName := uncli.ContextOverrideOrCurrent()
@@ -6,7 +6,6 @@ import (
"sort" "sort"
"time" "time"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -39,7 +38,10 @@ func rtt(ctx context.Context, uncli *cli.CLI) error {
defer client.Close() defer client.Close()
// Setup context to proxy request to all machines. // Setup context to proxy request to all machines.
ctx = client.ProxyMachinesContext(ctx, nil) ctx, _, err = client.ProxyMachinesContext(ctx, nil)
if err != nil {
return fmt.Errorf("setup proxy context: %w", err)
}
resp, err := client.MachineClient.InspectMachine(ctx, &emptypb.Empty{}) resp, err := client.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
if err != nil { if err != nil {
@@ -49,15 +51,6 @@ func rtt(ctx context.Context, uncli *cli.CLI) error {
// Map machine IDs to names for display from the response. // Map machine IDs to names for display from the response.
machineNames := make(map[string]string) machineNames := make(map[string]string)
for _, m := range resp.Machines { 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 err := m.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, err))
continue
}
if m.Machine == nil { if m.Machine == nil {
continue continue
} }
@@ -108,7 +101,7 @@ func rtt(ctx context.Context, uncli *cli.CLI) error {
t.Row(r.machine, r.peer, tui.FormatRTT(r.median), formatRTTStdDev(r.stdDev)) t.Row(r.machine, r.peer, tui.FormatRTT(r.median), formatRTTStdDev(r.stdDev))
} }
lipgloss.Println(t) fmt.Println(t)
return nil return nil
} }
@@ -6,9 +6,9 @@ import (
"net/netip" "net/netip"
"strings" "strings"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -70,8 +70,7 @@ 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 "+ fmt.Sprintf("WireGuard endpoint address that other machines in the cluster should use to establish "+
"WireGuard connections\n"+ "WireGuard connections\n"+
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\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", "Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is %d if omitted.\n", network.WireGuardPort)+
network.DefaultWireGuardPort)+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.", "Multiple endpoints can be specified by repeating the flag or using a comma-separated list.",
) )
@@ -90,13 +89,16 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
} }
defer client.Close() defer client.Close()
// Resolve the machine to capture its current configuration for the before/after report and to validate existence. // First, resolve the machine to get its ID
machine, err := client.InspectMachine(ctx, machineNameOrID) machine, err := client.InspectMachine(ctx, machineNameOrID)
if err != nil { if err != nil {
return fmt.Errorf("find machine: %w", err) return fmt.Errorf("find machine: %w", err)
} }
req := &pb.UpdateMachineRequest{} // Build the update request
req := &pb.UpdateMachineRequest{
MachineId: machine.Machine.Id,
}
if opts.name != "" { if opts.name != "" {
req.Name = &opts.name req.Name = &opts.name
@@ -119,7 +121,7 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
// Parse and set endpoints if the flag was explicitly provided. // Parse and set endpoints if the flag was explicitly provided.
if cmd.Flags().Changed("wg-endpoint") { if cmd.Flags().Changed("wg-endpoint") {
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints) expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
endpoints, err := cli.ParseWireGuardEndpoints(expanded, network.DefaultWireGuardPort) endpoints, err := cli.ParseWireGuardEndpoints(expanded)
if err != nil { if err != nil {
return err return err
} }
@@ -129,7 +131,8 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
req.Endpoints = endpoints req.Endpoints = endpoints
} }
updatedMachine, err := client.UpdateMachine(ctx, machine.Machine.Id, req) // Perform the update operation
updatedMachine, err := client.UpdateMachine(ctx, req)
if err != nil { if err != nil {
return fmt.Errorf("update machine: %w", err) return fmt.Errorf("update machine: %w", err)
} }
+18 -48
View File
@@ -8,23 +8,22 @@ import (
"os" "os"
"strings" "strings"
"github.com/psviderski/uncloud/cmd/uc/caddy" "charm.land/lipgloss/v2"
cmdcontext "github.com/psviderski/uncloud/cmd/uc/context" "github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uc/dns" cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
"github.com/psviderski/uncloud/cmd/uc/image" "github.com/psviderski/uncloud/cmd/uncloud/dns"
cmdmachine "github.com/psviderski/uncloud/cmd/uc/machine" "github.com/psviderski/uncloud/cmd/uncloud/image"
"github.com/psviderski/uncloud/cmd/uc/service" cmdmachine "github.com/psviderski/uncloud/cmd/uncloud/machine"
"github.com/psviderski/uncloud/cmd/uc/volume" "github.com/psviderski/uncloud/cmd/uncloud/service"
"github.com/psviderski/uncloud/cmd/uc/wg" "github.com/psviderski/uncloud/cmd/uncloud/volume"
"github.com/psviderski/uncloud/cmd/uncloud/wg"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/fs" "github.com/psviderski/uncloud/internal/fs"
"github.com/psviderski/uncloud/internal/log" "github.com/psviderski/uncloud/internal/log"
"github.com/psviderski/uncloud/internal/machine" "github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/version" "github.com/psviderski/uncloud/internal/version"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
type globalOptions struct { type globalOptions struct {
@@ -40,16 +39,10 @@ func main() {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "uc", Use: "uc",
Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.", Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.",
Version: version.String(),
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// 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, "connect", "UNCLOUD_CONNECT")
cli.BindEnvToFlag(cmd, "context", "UNCLOUD_CONTEXT") cli.BindEnvToFlag(cmd, "context", "UNCLOUD_CONTEXT")
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG") cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
@@ -88,11 +81,10 @@ func main() {
configPath := fs.ExpandHomeDir(opts.configPath) configPath := fs.ExpandHomeDir(opts.configPath)
// Make uc connect via the local Unix socket when running on a cluster machine.
if opts.connect == "" { if opts.connect == "" {
if !fs.Exists(configPath) && fs.Exists(machine.DefaultClusterAPISockPath) { if !fs.Exists(configPath) && fs.Exists(machine.DefaultUncloudSockPath) {
conn = &config.MachineConnection{ conn = &config.MachineConnection{
Unix: machine.DefaultClusterAPISockPath, Unix: machine.DefaultUncloudSockPath,
} }
} }
} }
@@ -121,11 +113,15 @@ func main() {
defaultHelpFunc(c, args) defaultHelpFunc(c, args)
// Only show links for the root 'uc' command. // Only show links for the root 'uc' command.
if c.Name() == "uc" { if c.Name() == "uc" {
urlStyle := lipgloss.NewStyle().
Underline(true).
Foreground(lipgloss.Color("12")) // light blue
fmt.Fprintln(c.OutOrStdout()) fmt.Fprintln(c.OutOrStdout())
fmt.Fprintf(c.OutOrStdout(), "Learn more about Uncloud: %s\n", fmt.Fprintf(c.OutOrStdout(), "Learn more about Uncloud: %s\n",
tui.URLStyle.Render(version.DocsURL)) urlStyle.Render("https://uncloud.run/docs"))
fmt.Fprintf(c.OutOrStdout(), "Join our Discord community: %s\n", fmt.Fprintf(c.OutOrStdout(), "Join our Discord community: %s\n",
tui.URLStyle.Render(version.DiscordURL)) urlStyle.Render("https://uncloud.run/discord"))
} }
}) })
@@ -140,7 +136,6 @@ func main() {
NewDocsCommand(), NewDocsCommand(),
NewImagesCommand(), NewImagesCommand(),
NewPsCommand(), NewPsCommand(),
NewProxyCommand(),
caddy.NewRootCommand(), caddy.NewRootCommand(),
cmdcontext.NewRootCommand(), cmdcontext.NewRootCommand(),
dns.NewRootCommand(), dns.NewRootCommand(),
@@ -156,7 +151,6 @@ func main() {
service.NewScaleCommand("service"), service.NewScaleCommand("service"),
service.NewStartCommand("service"), service.NewStartCommand("service"),
service.NewStopCommand("service"), service.NewStopCommand("service"),
NewVersionCommand(),
volume.NewRootCommand(), volume.NewRootCommand(),
wg.NewRootCommand(), wg.NewRootCommand(),
) )
@@ -168,27 +162,3 @@ func main() {
cobra.CheckErr(err) 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())
})
}
+48 -14
View File
@@ -6,6 +6,7 @@ import (
"sort" "sort"
"time" "time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/go-units" "github.com/docker/go-units"
@@ -82,10 +83,20 @@ func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
defer clusterClient.Close() defer clusterClient.Close()
var containers []containerInfo var containers []containerInfo
err = tui.RunSpinner(ctx, "Collecting container info...", func(ctx context.Context) error { err = spinner.New().
containers, err = collectContainers(ctx, clusterClient) Title(" Collecting container info...").
return err 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()
if err != nil { if err != nil {
return fmt.Errorf("collect containers: %w", err) return fmt.Errorf("collect containers: %w", err)
} }
@@ -182,12 +193,23 @@ func printContainers(containers []containerInfo) error {
} }
} }
lipgloss.Println(t) fmt.Println(t)
return nil return nil
} }
func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo, error) { func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo, error) {
listCtx := client.ProxyMachinesContext(ctx, nil) 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
}
}
// List all service containers across all machines in the cluster. // List all service containers across all machines in the cluster.
machineContainers, err := cli.Docker.ListServiceContainers( machineContainers, err := cli.Docker.ListServiceContainers(
@@ -199,17 +221,29 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
var containers []containerInfo var containers []containerInfo
for _, msc := range machineContainers { for _, msc := range machineContainers {
// NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed. // Metadata can be nil if the request was broadcasted to only one machine.
if msc.Metadata == nil { if msc.Metadata == nil && len(machineContainers) > 1 {
tui.PrintWarning("metadata is missing in response from unknown server") return nil, fmt.Errorf("something went wrong with gRPC proxy: metadata is missing for a machine response")
continue
} }
machineName := msc.Metadata.MachineName 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
}
}
if err := msc.Metadata.Err(); err != nil { if msc.Metadata != nil && msc.Metadata.Error != "" {
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s", tui.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
machineName, err)) msc.Metadata.Error))
continue continue
} }
+441
View File
@@ -0,0 +1,441 @@
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)
}
}
@@ -3,11 +3,9 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"maps"
"slices" "slices"
"time" "time"
"charm.land/lipgloss/v2"
"github.com/docker/docker/pkg/stringid" "github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -56,19 +54,18 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
return fmt.Errorf("inspect service: %w", err) 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("Service ID: %s\n", svc.ID)
fmt.Printf("Name: %s\n", svc.Name) fmt.Printf("Name: %s\n", svc.Name)
fmt.Printf("Mode: %s\n", svc.Mode) fmt.Printf("Mode: %s\n", svc.Mode)
spec := svc.Spec()
if len(spec.Labels) > 0 {
fmt.Println("Labels:")
printLabels(spec.Labels)
}
if len(spec.Container.Labels) > 0 {
fmt.Println("Container labels:")
printLabels(spec.Container.Labels)
}
fmt.Println() fmt.Println()
// Combine regular and hook containers. // Combine regular and hook containers.
@@ -100,7 +97,7 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
for _, ctr := range allContainers { for _, ctr := range allContainers {
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago" created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
machine := ctr.MachineName machine := machinesNamesByID[ctr.MachineID]
if machine == "" { if machine == "" {
machine = ctr.MachineID machine = ctr.MachineID
} }
@@ -138,13 +135,6 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
} }
} }
lipgloss.Println(t) fmt.Println(t)
return nil return nil
} }
// printLabels prints the labels sorted by key followed by a blank line.
func printLabels(labels map[string]string) {
for _, key := range slices.Sorted(maps.Keys(labels)) {
lipgloss.Println(" " + key + tui.Faint.Render("=") + labels[key])
}
}
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -60,7 +59,7 @@ If no services are specified, streams logs from all services defined in the Comp
uc logs -m machine1,machine2 web api`, uc logs -m machine1,machine2 web api`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return RunLogs(cmd.Context(), uncli, args, options) return runLogs(cmd.Context(), uncli, args, options)
}, },
GroupID: groupID, GroupID: groupID,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
@@ -75,19 +74,12 @@ If no services are specified, streams logs from all services defined in the Comp
cmd.Flags().AddFlagSet(logs.Flags(&options)) cmd.Flags().AddFlagSet(logs.Flags(&options))
completion.MachinesFlag(cmd) completion.MachinesFlag(cmd)
completion.MachinesFlag(cmd)
return 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 {
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
if err != nil {
return err
}
tail, err := opts.TailLines()
if err != nil {
return err
}
serviceArgs, err := logs.ParseServiceArgs(args) serviceArgs, err := logs.ParseServiceArgs(args)
if err != nil { if err != nil {
return err return err
@@ -116,6 +108,12 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
} }
} }
// Parse tail option.
tail, err := logs.Tail(opts.Tail)
if err != nil {
return err
}
c, err := uncli.ConnectCluster(ctx) c, err := uncli.ConnectCluster(ctx)
if err != nil { if err != nil {
return fmt.Errorf("connect to cluster: %w", err) return fmt.Errorf("connect to cluster: %w", err)
@@ -125,8 +123,8 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
baseOpts := api.ServiceLogsOptions{ baseOpts := api.ServiceLogsOptions{
Follow: opts.Follow, Follow: opts.Follow,
Tail: tail, Tail: tail,
Since: since, Since: opts.Since,
Until: until, Until: opts.Until,
Machines: cli.ExpandCommaSeparatedValues(opts.Machines), Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
} }
@@ -6,7 +6,6 @@ import (
"slices" "slices"
"strings" "strings"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
@@ -83,6 +82,6 @@ func list(ctx context.Context, uncli *cli.CLI) error {
t.Row(row...) t.Row(row...)
} }
lipgloss.Println(t) fmt.Println(t)
return nil return nil
} }
@@ -20,7 +20,6 @@ func NewRootCommand() *cobra.Command {
NewScaleCommand(""), NewScaleCommand(""),
NewStartCommand(""), NewStartCommand(""),
NewStopCommand(""), NewStopCommand(""),
NewUpdateCommand(),
) )
return cmd return cmd
} }
@@ -21,16 +21,13 @@ import (
type runOptions struct { type runOptions struct {
caddyfile string caddyfile string
command []string command []string
containerLabels []string
cpu dockeropts.NanoCPUs cpu dockeropts.NanoCPUs
entrypoint string entrypoint string
entrypointChanged bool entrypointChanged bool
env []string env []string
image string image string
labels []string
machines []string machines []string
memory dockeropts.MemBytes memory dockeropts.MemBytes
memoryReservation dockeropts.MemBytes
mode string mode string
shmSize dockeropts.MemBytes shmSize dockeropts.MemBytes
name string name string
@@ -67,9 +64,6 @@ func NewRunCommand(groupID string) *cobra.Command {
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "", cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
"Path to a custom Caddy config (Caddyfile) for the service. "+ "Path to a custom Caddy config (Caddyfile) for the service. "+
"Cannot be used together with non-@host published ports.") "Cannot be used together with non-@host published ports.")
cmd.Flags().StringArrayVar(&opts.containerLabels, "container-label", nil,
"Set a Docker label on service containers. Can be specified multiple times.\n"+
"Format: key=value or just key for an empty value.")
cmd.Flags().VarP(&opts.cpu, "cpu", "", cmd.Flags().VarP(&opts.cpu, "cpu", "",
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+ "Maximum number of CPU cores a service container can use. Fractional values are allowed: "+
"0.5 for half a core or 2.25 for two and a quarter cores.") "0.5 for half a core or 2.25 for two and a quarter cores.")
@@ -78,9 +72,6 @@ func NewRunCommand(groupID string) *cobra.Command {
cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil, cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil,
"Set an environment variable for service containers. Can be specified multiple times.\n"+ "Set an environment variable for service containers. Can be specified multiple times.\n"+
"Format: VAR=value or just VAR to use the value from the local environment.") "Format: VAR=value or just VAR to use the value from the local environment.")
cmd.Flags().StringArrayVarP(&opts.labels, "label", "l", nil,
"Set a service label. Service labels aren't applied to Docker containers, use --container-label for that.\n"+
"Can be specified multiple times. Format: key=value or just key for an empty value.")
cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated, cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+ fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+
"the machines) or '%s' (one container on every machine).", "the machines) or '%s' (one container on every machine).",
@@ -92,9 +83,6 @@ func NewRunCommand(groupID string) *cobra.Command {
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+ "Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
"Default unit is bytes if no suffix specified.\n"+ "Default unit is bytes if no suffix specified.\n"+
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)") "Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
cmd.Flags().Var(&opts.memoryReservation, "memory-reservation",
"Minimum amount of memory reserved for a service container. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
"Default unit is bytes if no suffix specified.")
cmd.Flags().VarP(&opts.shmSize, "shm-size", "", cmd.Flags().VarP(&opts.shmSize, "shm-size", "",
"Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer\n"+ "Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer\n"+
"with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+ "with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
@@ -105,18 +93,15 @@ func NewRunCommand(groupID string) *cobra.Command {
"Give extended privileges to service containers. This is a security risk and should be used with caution.") "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, 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"+ "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_prefix:]host_port:container_port[/protocol]@host\n"+ "Format: [hostname:]container_port[/protocol] or [host_ip:]host_port:container_port[/protocol]@host\n"+
"Supported protocols: tcp, udp, http, https. The protocol defaults to https if a hostname is specified,\n"+ "Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified\n"+
"otherwise tcp. http(s) ports without a hostname use 'service-name.cluster-domain' as the default hostname\n"+ "and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+
"if a cluster domain is reserved.\n"+
"Examples:\n"+ "Examples:\n"+
" -p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname\n"+ " -p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname\n"+
" -p app.example.com:8080 Publish port 8080 as HTTPS via reverse proxy with custom 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. // TODO: add support for publishing L4 tcp/udp ports.
//" -p 9000:8080 Publish port 8080 as TCP port 9000 via reverse proxy\n"+ //" -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\n"+ " -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
" -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, cmd.Flags().StringVar(&opts.pull, "pull", api.PullPolicyMissing,
fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').", fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').",
api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever)) api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever))
@@ -135,7 +120,6 @@ func NewRunCommand(groupID string) *cobra.Command {
"Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+ "Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+
"the volume is located. Can be specified multiple times.\n"+ "the volume is located. Can be specified multiple times.\n"+
"Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]\n"+ "Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]\n"+
"Host and container paths support runtime templates such as {{.Container.Name}}.\n"+
"Examples:\n"+ "Examples:\n"+
" -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container\n"+ " -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container\n"+
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+ " -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
@@ -205,18 +189,6 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
return spec, err return spec, err
} }
labels, err := parseLabels(opts.labels)
if err != nil {
return spec, err
}
if err = api.ValidateLabels(labels); err != nil {
return spec, fmt.Errorf("invalid service labels: %w", err)
}
containerLabels, err := parseLabels(opts.containerLabels)
if err != nil {
return spec, err
}
switch opts.mode { switch opts.mode {
case api.ServiceModeReplicated, api.ServiceModeGlobal: case api.ServiceModeReplicated, api.ServiceModeGlobal:
default: default:
@@ -257,20 +229,17 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
Command: opts.command, Command: opts.command,
Env: env, Env: env,
Image: opts.image, Image: opts.image,
Labels: containerLabels,
Privileged: opts.privileged, Privileged: opts.privileged,
PullPolicy: opts.pull, PullPolicy: opts.pull,
Resources: api.ContainerResources{ Resources: api.ContainerResources{
CPU: opts.cpu.Value(), CPU: opts.cpu.Value(),
Memory: opts.memory.Value(), Memory: opts.memory.Value(),
MemoryReservation: opts.memoryReservation.Value(), SharedMemory: opts.shmSize.Value(),
SharedMemory: opts.shmSize.Value(), Ulimits: ulimits,
Ulimits: ulimits,
}, },
User: opts.user, User: opts.user,
VolumeMounts: mounts, VolumeMounts: mounts,
}, },
Labels: labels,
Mode: opts.mode, Mode: opts.mode,
Name: opts.name, Name: opts.name,
Placement: placement, Placement: placement,
@@ -329,24 +298,6 @@ func parseEnv(env []string) (api.EnvVars, error) {
return envVars, nil return envVars, nil
} }
// parseLabels parses labels from the command line arguments in the format "key=value" or just "key" for an empty value.
func parseLabels(labels []string) (map[string]string, error) {
if len(labels) == 0 {
return nil, nil
}
result := make(map[string]string, len(labels))
for _, label := range labels {
key, value, _ := strings.Cut(label, "=")
if strings.TrimSpace(key) == "" {
return nil, fmt.Errorf("invalid label '%s': key must not be empty", label)
}
result[key] = value
}
return result, nil
}
// parseVolumeFlags parses volume flag values in Docker CLI format and returns VolumeSpecs and VolumeMounts. // parseVolumeFlags parses volume flag values in Docker CLI format and returns VolumeSpecs and VolumeMounts.
// It handles both named volumes (volume_name:/container/path[:ro|volume-nocopy]) // It handles both named volumes (volume_name:/container/path[:ro|volume-nocopy])
// and bind mounts (/host/path:/container/path[:ro]). // and bind mounts (/host/path:/container/path[:ro]).
@@ -30,11 +30,9 @@ func NewScaleCommand(groupID string) *cobra.Command {
Short: "Scale a replicated service by changing the number of replicas.", Short: "Scale a replicated service by changing the number of replicas.",
Long: "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), Args: cobra.ExactArgs(2),
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 { RunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0] opts.service = args[0]
@@ -138,7 +136,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
// Ask for confirmation unless auto-confirmed with --yes. // Ask for confirmation unless auto-confirmed with --yes.
if !opts.yes { if !opts.yes {
if !tui.IsTerminalAvailable() { if !tui.IsStdinTerminal() {
return errors.New("cannot ask to confirm scaling plan in non-interactive mode, " + 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") "use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
} }
@@ -8,9 +8,9 @@ import (
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -6,7 +6,6 @@ import (
"slices" "slices"
"strings" "strings"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
@@ -96,6 +95,6 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
t.Row(v.Volume.Name, v.Volume.Driver, v.MachineName) t.Row(v.Volume.Name, v.Volume.Driver, v.MachineName)
} }
lipgloss.Println(t) fmt.Println(t)
return nil return nil
} }
@@ -92,7 +92,8 @@ func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOpti
return fmt.Errorf("confirm removal: %w", err) return fmt.Errorf("confirm removal: %w", err)
} }
if !confirmed { if !confirmed {
return cli.Cancelled("Cancelled. No volumes were removed.") fmt.Println("Cancelled. No volumes were removed.")
return nil
} }
} }
+6 -4
View File
@@ -6,12 +6,11 @@ import (
"strings" "strings"
"time" "time"
"charm.land/lipgloss/v2"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
@@ -61,7 +60,10 @@ func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
if opts.machine != "" { if opts.machine != "" {
// Proxy requests to the specified machine. // Proxy requests to the specified machine.
ctx = client.ProxySingleMachineContext(ctx, opts.machine) ctx, _, err = client.ProxyMachinesContext(ctx, []string{opts.machine})
if err != nil {
return err
}
} }
resp, err := client.MachineClient.InspectWireGuardNetwork(ctx, nil) resp, err := client.MachineClient.InspectWireGuardNetwork(ctx, nil)
@@ -134,6 +136,6 @@ func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
) )
} }
lipgloss.Println(t) fmt.Println(t)
return nil return nil
} }
-5
View File
@@ -1,5 +0,0 @@
_ _ _
_ _ _ __ ___| | ___ _ _ __| | __| |
| | | | '_ \ / __| |/ _ \| | | |/ _` |/ _` |
| |_| | | | | (__| | (_) | |_| | (_| | (_| |
\__,_|_| |_|\___|_|\___/ \__,_|\__,_|\__,_|
+1 -1
View File
@@ -23,7 +23,7 @@ func newDialStdioCommand() *cobra.Command {
}, },
} }
cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultClusterAPISockPath, cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultUncloudSockPath,
"Path to the Uncloud API socket") "Path to the Uncloud API socket")
return cmd return cmd
+3 -7
View File
@@ -7,11 +7,9 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/daemon" "github.com/psviderski/uncloud/internal/daemon"
"github.com/psviderski/uncloud/internal/log" "github.com/psviderski/uncloud/internal/log"
"github.com/psviderski/uncloud/internal/machine" "github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/metrics"
"github.com/psviderski/uncloud/internal/version" "github.com/psviderski/uncloud/internal/version"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -26,7 +24,7 @@ func main() {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "uncloudd", Use: "uncloudd",
Short: "Uncloud machine daemon.", Short: "Uncloud machine daemon.",
Long: "Uncloud machine daemon.\n" + tui.URLStyle.Render(version.WebsiteURL), Version: version.String(),
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -41,13 +39,11 @@ func main() {
}, },
} }
cmd.PersistentFlags().StringVarP(&dataDir, "data-dir", "d", machine.DefaultDataDir, 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") _ = cmd.MarkFlagDirname("data-dir")
// Add dial-stdio subcommand.
cmd.AddCommand(newDialStdioCommand()) cmd.AddCommand(newDialStdioCommand())
cmd.AddCommand(newVersionCommand())
metrics.Version.WithLabelValues(version.String()).Set(1)
// ctx is canceled when the daemon command is interrupted. // ctx is canceled when the daemon command is interrupted.
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
-82
View File
@@ -1,82 +0,0 @@
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
}
+22 -27
View File
@@ -1,6 +1,6 @@
module github.com/psviderski/uncloud/experiment module github.com/psviderski/uncloud/experiment
go 1.26.0 go 1.26
require ( require (
github.com/dgraph-io/badger/v3 v3.2103.5 github.com/dgraph-io/badger/v3 v3.2103.5
@@ -18,22 +18,17 @@ require (
github.com/psviderski/uncloud v0.0.0 github.com/psviderski/uncloud v0.0.0
github.com/siderolabs/discovery-api v0.1.4 github.com/siderolabs/discovery-api v0.1.4
github.com/siderolabs/discovery-client v0.1.9 github.com/siderolabs/discovery-client v0.1.9
go.uber.org/zap v1.28.0 go.uber.org/zap v1.27.0
golang.org/x/net v0.59.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
google.golang.org/grpc v1.81.0
) )
require ( require (
github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
github.com/armon/go-metrics v0.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect
github.com/armon/go-radix v1.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect
github.com/bgentry/speakeasy v0.2.0 // indirect github.com/bgentry/speakeasy v0.2.0 // indirect
github.com/caarlos0/go-version v0.2.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -79,18 +74,18 @@ require (
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect
github.com/jbenet/goprocess v0.1.4 // indirect github.com/jbenet/goprocess v0.1.4 // indirect
github.com/josharian/native v1.1.0 // indirect github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.19.1 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect
github.com/libp2p/go-libp2p v0.35.4 // indirect github.com/libp2p/go-libp2p v0.35.4 // indirect
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
github.com/libp2p/go-msgio v0.3.0 // indirect github.com/libp2p/go-msgio v0.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.5.1 // indirect
github.com/miekg/dns v1.1.73 // indirect github.com/miekg/dns v1.1.65 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mitchellh/cli v1.1.5 // indirect github.com/mitchellh/cli v1.1.5 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
@@ -113,32 +108,32 @@ require (
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/siderolabs/gen v0.4.8 // indirect github.com/siderolabs/gen v0.4.8 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cast v1.7.0 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.57.0 // indirect golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.41.0 // indirect golang.org/x/mod v0.27.0 // indirect
golang.org/x/sync v0.23.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/time v0.16.0 // indirect golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.50.0 // indirect golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/grpc v1.74.2 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
lukechampine.com/blake3 v1.3.0 // indirect lukechampine.com/blake3 v1.3.0 // indirect
) )
+4 -35
View File
@@ -5,8 +5,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
@@ -40,8 +38,6 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E= github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E=
github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -302,10 +298,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0=
github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk=
@@ -351,7 +345,6 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
@@ -371,7 +364,6 @@ github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKju
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc= github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc= github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc=
@@ -533,7 +525,6 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
@@ -586,20 +577,16 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc=
go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
@@ -616,7 +603,6 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -632,7 +618,6 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
@@ -646,7 +631,6 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -666,7 +650,6 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -677,9 +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-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-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -712,9 +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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -726,10 +707,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -744,13 +721,10 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE=
@@ -763,7 +737,6 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
@@ -772,7 +745,6 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -784,7 +756,6 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -805,8 +776,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ=
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
+1 -1
View File
@@ -81,7 +81,7 @@ func TestDiscovery() error {
} }
endpoints[i] = &pb.Endpoint{ endpoints[i] = &pb.Endpoint{
Ip: ip, Ip: ip,
Port: network.DefaultWireGuardPort, Port: network.WireGuardPort,
} }
} }
if err = client.SetLocalData( if err = client.SetLocalData(
+159 -103
View File
@@ -1,22 +1,20 @@
module github.com/psviderski/uncloud module github.com/psviderski/uncloud
go 1.26.0 go 1.26
require ( require (
charm.land/bubbles/v2 v2.0.0 charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.10 charm.land/bubbletea/v2 v2.0.2
charm.land/huh/v2 v2.0.1 charm.land/huh/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.1
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.5.0
github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver v1.5.0
github.com/Masterminds/squirrel v1.5.4 github.com/Masterminds/squirrel v1.5.4
github.com/alecthomas/chroma/v2 v2.24.1 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/caddyserver/caddy/v2 v2.11.4
github.com/caddyserver/certmagic v0.25.4
github.com/cenkalti/backoff/v4 v4.3.0 github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/colorprofile v0.4.3 github.com/charmbracelet/colorprofile v0.4.2
github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/ansi v0.11.6
github.com/compose-spec/compose-go/v2 v2.9.0 github.com/compose-spec/compose-go/v2 v2.9.0
github.com/containerd/errdefs v1.0.0 github.com/containerd/errdefs v1.0.0
github.com/containerd/platforms v1.0.0-rc.1 github.com/containerd/platforms v1.0.0-rc.1
@@ -31,72 +29,78 @@ require (
github.com/goccy/go-yaml v1.17.1 github.com/goccy/go-yaml v1.17.1
github.com/google/go-cmp v0.7.0 github.com/google/go-cmp v0.7.0
github.com/google/go-containerregistry v0.20.2 github.com/google/go-containerregistry v0.20.2
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.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/mholt/acmez/v3 v3.1.6
github.com/miekg/dns v1.1.73
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/moby/term v0.5.2 github.com/moby/term v0.5.2
github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/prometheus/client_golang v1.24.1
github.com/psviderski/unregistry v0.4.1 github.com/psviderski/unregistry v0.4.1
github.com/siderolabs/grpc-proxy v0.5.1 github.com/siderolabs/grpc-proxy v0.5.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.11.1
github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netlink v1.3.1
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.41.0
golang.org/x/net v0.59.0 golang.org/x/net v0.43.0
golang.org/x/sync v0.23.0 golang.org/x/sync v0.19.0
golang.org/x/sys v0.48.0 golang.org/x/sys v0.42.0
golang.org/x/term v0.46.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 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a
google.golang.org/grpc v1.81.0 google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.12 google.golang.org/protobuf v1.36.9
modernc.org/sqlite v1.49.1 modernc.org/sqlite v1.36.3
tags.cncf.io/container-device-interface v1.0.1 tags.cncf.io/container-device-interface v1.0.1
) )
require ( require (
cloud.google.com/go/kms v1.19.0 // indirect
cloud.google.com/go/longrunning v0.6.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/hcsshim v0.13.0 // indirect github.com/Microsoft/hcsshim v0.13.0 // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect github.com/aws/smithy-go v1.20.3 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/buger/goterm v1.0.4 // indirect github.com/buger/goterm v1.0.4 // indirect
github.com/caddyserver/zerossl v0.1.5 // indirect github.com/caddyserver/certmagic v0.21.4 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // 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/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/cgroups/v3 v3.0.5 // indirect github.com/containerd/cgroups/v3 v3.0.5 // indirect
@@ -113,9 +117,13 @@ require (
github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgraph-io/badger v1.6.2 // indirect
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/distribution/v3 v3.0.0 // indirect github.com/distribution/distribution/v3 v3.0.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/buildx v0.29.1 // indirect github.com/docker/buildx v0.29.1 // indirect
github.com/docker/cli-docs-tool v0.10.0 // indirect github.com/docker/cli-docs-tool v0.10.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect
@@ -129,59 +137,86 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsevents v0.2.0 // indirect github.com/fsnotify/fsevents v0.2.0 // indirect
github.com/fvbommel/sortorder v1.1.0 // indirect github.com/fvbommel/sortorder v1.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/go-kit/kit v0.13.0 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/glog v1.2.5 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/cel-go v0.20.1 // indirect
github.com/google/gnostic-models v0.6.8 // indirect github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/gofuzz v1.2.0 // indirect 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/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/handlers v1.5.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru/arc/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.7 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/huandu/xstrings v1.5.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/in-toto/in-toto-golang v0.9.0 // indirect github.com/in-toto/in-toto-golang v0.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.14.3 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgtype v1.14.0 // indirect
github.com/jackc/pgx/v4 v4.18.3 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/josharian/native v1.1.0 // indirect github.com/josharian/native v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.19.1 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/libdns/libdns v1.1.1 // indirect github.com/libdns/libdns v0.2.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // 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-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.5.1 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/miekg/pkcs11 v1.1.2 // indirect github.com/mholt/acmez/v2 v2.0.3 // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/buildkit v0.25.0 // indirect github.com/moby/buildkit v0.25.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect github.com/moby/go-archive v0.1.0 // indirect
@@ -202,36 +237,46 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/onsi/ginkgo/v2 v2.22.0 // indirect github.com/onsi/ginkgo/v2 v2.22.0 // indirect
github.com/opencontainers/runtime-spec v1.2.1 // indirect github.com/opencontainers/runtime-spec v1.2.1 // indirect
github.com/opencontainers/selinux v1.12.0 // indirect github.com/opencontainers/selinux v1.12.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pires/go-proxyproto v0.7.0 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/prometheus/client_model v0.6.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/common v0.71.0 // indirect github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/procfs v0.22.0 // indirect github.com/prometheus/common v0.62.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/quic-go/quic-go v0.62.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.48.2 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
github.com/redis/go-redis/v9 v9.7.3 // indirect github.com/redis/go-redis/v9 v9.7.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.6.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.6.0 // indirect
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/spf13/afero v1.14.0 // indirect github.com/slackhq/nebula v1.6.1 // indirect
github.com/spf13/viper v1.12.0 // indirect github.com/smallstep/certificates v0.26.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/smallstep/nosql v0.6.1 // indirect
github.com/subosito/gotenv v1.4.1 // indirect github.com/smallstep/pkcs7 v0.1.1 // indirect
github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
github.com/smallstep/truststore v0.13.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect github.com/theupdateframework/notary v0.7.0 // indirect
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect
@@ -239,6 +284,7 @@ require (
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
github.com/urfave/cli v1.22.17 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect github.com/vbatts/tar-split v0.12.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
@@ -246,57 +292,67 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zclconf/go-cty v1.17.0 // indirect github.com/zclconf/go-cty v1.17.0 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 // indirect go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
go.opentelemetry.io/otel/log v0.19.0 // indirect go.opentelemetry.io/otel/log v0.8.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.36.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.step.sm/cli-utils v0.9.0 // indirect
go.step.sm/crypto v0.45.0 // indirect
go.step.sm/linkedca v0.20.1 // indirect
go.uber.org/automaxprocs v1.5.3 // indirect
go.uber.org/mock v0.6.0 // indirect go.uber.org/mock v0.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect go.uber.org/zap/exp v0.2.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/time v0.16.0 // indirect golang.org/x/mod v0.27.0 // indirect
golang.org/x/tools v0.50.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect golang.org/x/text v0.28.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
howett.net/plist v1.0.0 // indirect
k8s.io/api v0.32.3 // indirect k8s.io/api v0.32.3 // indirect
k8s.io/apimachinery v0.32.3 // indirect k8s.io/apimachinery v0.32.3 // indirect
k8s.io/client-go v0.32.3 // indirect k8s.io/client-go v0.32.3 // indirect
k8s.io/klog/v2 v2.130.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
modernc.org/libc v1.72.0 // indirect modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.8.2 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect
+575 -240
View File
File diff suppressed because it is too large Load Diff
+14 -48
View File
@@ -8,11 +8,9 @@ import (
"slices" "slices"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/machine" "github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/machine/cluster" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/sshexec" "github.com/psviderski/uncloud/internal/sshexec"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
@@ -176,8 +174,6 @@ type InitClusterOptions struct {
Version string Version string
AutoConfirm bool AutoConfirm bool
WireguardEndpoints []*pb.IPPort 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. // InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
@@ -238,8 +234,6 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
MachineName: opts.MachineName, MachineName: opts.MachineName,
Network: pb.NewIPPrefix(opts.Network), Network: pb.NewIPPrefix(opts.Network),
WireguardEndpoints: opts.WireguardEndpoints, WireguardEndpoints: opts.WireguardEndpoints,
WireguardMtu: int32(opts.WireguardMTU),
WireguardPort: int32(opts.WireguardPort),
} }
if opts.PublicIP != nil { if opts.PublicIP != nil {
if opts.PublicIP.IsValid() { if opts.PublicIP.IsValid() {
@@ -323,15 +317,13 @@ type AddMachineOptions struct {
Version string Version string
AutoConfirm bool AutoConfirm bool
WireguardEndpoints []*pb.IPPort 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. // 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 // 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. // 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. // Both client should be closed after use by the caller.
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (_ *client.Client, _ *client.Client, err error) { func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) {
contextName := cli.ContextOverrideOrCurrent() contextName := cli.ContextOverrideOrCurrent()
c, err := cli.ConnectCluster(ctx) c, err := cli.ConnectCluster(ctx)
if err != nil { if err != nil {
@@ -352,19 +344,16 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (_ *clie
machineClient.Close() 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. // Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
inspectResp, err := machineClient.MachineClient.InspectMachine(ctx, nil) // TODO: refactor to use client.InspectMachine.
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("inspect machine: %w", err) return nil, nil, fmt.Errorf("inspect machine: %w", err)
} }
minfo := inspectResp.Machines[0].Machine
if minfo.Id != "" { if minfo.Id != "" {
// Check if the machine is already a member of this cluster. // Check if the machine is already a member of this cluster.
var machines api.MachineMembersList machines, err := c.ListMachines(ctx, nil)
machines, err = c.ListMachines(ctx, nil)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("list cluster machines: %w", err) return nil, nil, fmt.Errorf("list cluster machines: %w", err)
} }
@@ -412,32 +401,11 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (_ *clie
} else { } else {
endpoints = make([]*pb.IPPort, len(token.Endpoints)) endpoints = make([]*pb.IPPort, len(token.Endpoints))
for i, addrPort := range 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) 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{ addReq := &pb.AddMachineRequest{
Name: machineName, Name: opts.MachineName,
Network: &pb.NetworkConfig{ Network: &pb.NetworkConfig{
Endpoints: endpoints, Endpoints: endpoints,
PublicKey: token.PublicKey, PublicKey: token.PublicKey,
@@ -457,16 +425,16 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (_ *clie
return nil, nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err) return nil, nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err)
} }
// Snapshot the cluster store version so the new machine can catch up before participating. // Get the current store DB version from the cluster to pass to the join request.
var storeVersion api.StoreVersion var storeDBVersion int64
inspectResp, err = c.MachineClient.InspectMachine(ctx, &emptypb.Empty{}) inspectResp, err := c.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
if err != nil { if err != nil {
// TODO(lhf): remove Unimplemented check when v0.17.0 is released. // TODO(lhf): remove Unimplemented check when v0.17.0 is released.
if status.Convert(err).Code() != codes.Unimplemented { if status.Convert(err).Code() != codes.Unimplemented {
return nil, nil, fmt.Errorf("inspect current cluster machine: %w", err) return nil, nil, fmt.Errorf("inspect current cluster machine: %w", err)
} }
} else { } else {
storeVersion = inspectResp.Machines[0].StoreVersion storeDBVersion = inspectResp.Machines[0].StoreDbVersion
} }
// Get the most up-to-date list of other machines in the cluster to include them in the join request. // Get the most up-to-date list of other machines in the cluster to include them in the join request.
@@ -483,11 +451,9 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (_ *clie
// Configure the remote machine to join the cluster. // Configure the remote machine to join the cluster.
joinReq := &pb.JoinClusterRequest{ joinReq := &pb.JoinClusterRequest{
Machine: addResp.Machine, Machine: addResp.Machine,
OtherMachines: otherMachines, OtherMachines: otherMachines,
MinStoreVersion: storeVersion, MinStoreDbVersion: storeDBVersion,
WireguardMtu: int32(opts.WireguardMTU),
WireguardPort: int32(opts.WireguardPort),
} }
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil { if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
return nil, nil, fmt.Errorf("join cluster: %w", err) return nil, nil, fmt.Errorf("join cluster: %w", err)
@@ -603,7 +569,7 @@ func provisionOrConnectRemoteMachine(
if remoteMachine.User != rootUser { if remoteMachine.User != rootUser {
// provisionMachine has just added the user to the uncloud group. Any SSH ControlMaster left over from // provisionMachine has just added the user to the uncloud group. Any SSH ControlMaster left over from
// a previous uc invocation (e.g. a failed uc command against the uninitialised machine) still holds // a previous uc invocation (e.g. a failed uc command against the uninitialised machine) still holds
// the old user groups and would deny access to /run/uncloud/api/uncloud.sock. Close the current session // the old user groups and would deny access to /run/uncloud/uncloud.sock. Close the current session
// if it exists so the next session picks up the new groups. // if it exists so the next session picks up the new groups.
conn.CloseControlMaster(ctx) conn.CloseControlMaster(ctx)
} }

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