Compare commits

..
Author SHA1 Message Date
Pasha Sviderski f280003d87 . 2026-04-01 16:15:02 +10:00
Pasha Sviderski 531cebfff0 docs: CLI configuration file reference 2026-04-01 16:14:17 +10:00
482 changed files with 10824 additions and 53186 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 KiB

+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
-99
View File
@@ -1,99 +0,0 @@
name: Nightly Build
on:
push:
branches:
- main
permissions:
contents: write
env:
MISE_VERSION: "2026.10.22"
jobs:
nightly:
# Run on macOS to be able to build the fsevent dependency required by compose on macOS.
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Install Mise
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with:
version: ${{ env.MISE_VERSION }}
- name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock
- name: Cache Go build and module cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
~/Library/Caches/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-${{ hashFiles('**/*.go') }}
restore-keys: |
${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-
${{ runner.os }}-go-
- name: Run GoReleaser snapshot
run: goreleaser release --snapshot --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Delete existing nightly release
run: gh release delete nightly --yes --cleanup-tag || true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create nightly release
run: |
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date -u '+%Y-%m-%d %H:%M UTC')
RELEASE_NOTES=$(cat <<EOF
Built from \`main\` at ${COMMIT} on ${DATE}.
The commands below show how to install the nightly version of uncloud.
> [!WARNING]
> These are nightly development builds and may be unstable.
**Install uncloud CLI:**
\`\`\`
curl -fsS https://get.uncloud.run/install.sh | VERSION=nightly sh
\`\`\`
**Initialize a cluster or add a machine:**
\`\`\`
uc machine init --version nightly user@host
uc machine add --version nightly user@host
\`\`\`
**Upgrade machine daemon:**
\`\`\`
ARCH=\$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
curl -fsSL -o uncloudd.tar.gz https://github.com/psviderski/uncloud/releases/download/nightly/uncloudd_linux_\${ARCH}.tar.gz
tar -xf uncloudd.tar.gz
sudo install uncloudd /usr/local/bin/uncloudd
rm uncloudd uncloudd.tar.gz
sudo systemctl restart uncloud
\`\`\`
EOF
)
gh release create nightly \
--title "Nightly Build" \
--notes "${RELEASE_NOTES}" \
--prerelease \
--target ${{ github.sha }} \
dist/*.tar.gz dist/checksums.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
timeout-minutes: 30
-1
View File
@@ -44,4 +44,3 @@ node_modules/
.devcontainer/ .devcontainer/
.zed/ .zed/
research/
+10 -27
View File
@@ -11,18 +11,10 @@ before:
report_sizes: true report_sizes: true
git:
# Skip the stale 'nightly' tag so {{.Version}} resolves to the latest semver tag for --snapshot builds.
ignore_tags:
- nightly
snapshot:
# Semver-compliant nightly version, e.g. "0.19.0-nightly-abc1234" when the latest tag is v0.18.0.
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 +26,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 +39,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 +85,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 -11
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,7 +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 the `testify` library for assertions (e.g., `require.Equal`, `assert.Nil`) - Use table driven tests whenever possible
### Dependencies ### Dependencies
@@ -213,7 +238,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
+7 -11
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
@@ -48,9 +46,7 @@ RUN apk --no-cache add crane
RUN crane pull --platform ${TARGETOS}/${TARGETARCH} "${CORROSION_IMAGE}" /corrosion.tar RUN crane pull --platform ${TARGETOS}/${TARGETARCH} "${CORROSION_IMAGE}" /corrosion.tar
# 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:27.3.1-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 \
@@ -58,7 +54,7 @@ RUN apk --no-cache add \
wireguard-tools wireguard-tools
COPY --from=corrosion-image-tarball /corrosion.tar /images/corrosion.tar COPY --from=corrosion-image-tarball /corrosion.tar /images/corrosion.tar
COPY scripts/docker/entrypoint.sh /usr/local/bin/ COPY scripts/docker/dind scripts/docker/entrypoint.sh /usr/local/bin/
COPY --from=uncloudd /build/uncloudd /usr/local/bin/ COPY --from=uncloudd /build/uncloudd /usr/local/bin/
ENTRYPOINT ["entrypoint.sh"] ENTRYPOINT ["entrypoint.sh"]
+10 -40
View File
@@ -5,8 +5,7 @@ A short guide on setting up your local development environment.
## Prerequisites ## Prerequisites
- [mise](https://mise.jdx.dev/) (development environment setup tool) - [mise](https://mise.jdx.dev/) (development environment setup tool)
- [Docker](https://docs.docker.com/get-docker/) with BuildKit (for running end-to-end tests). Make sure the - [Docker](https://docs.docker.com/get-docker/) (for running end-to-end tests)
[`docker buildx`](https://docs.docker.com/build/concepts/overview/) plugin is installed.
## Setup ## Setup
@@ -32,14 +31,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 +98,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,38 +115,16 @@ 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.
Each cluster machine runs as a Docker container connected to a shared Docker network. Each cluster machine runs as a Docker container connected to a shared Docker network.
Cluster machines use the `ghcr.io/psviderski/ucind:latest` Docker image by default. It may not always have the latest Cluster machines use the `ghcr.io/psviderski/ucind:latest` Docker image by default. It may not always have the
changes to the `uncloudd` daemon code as it's not automatically rebuilt on CI yet. latest changes to the `uncloudd` daemon code as it's not automatically rebuilt on CI yet.
You can rebuild it locally to pick up the latest changes, including your work in progress, and test them in a UCinD You can rebuild it locally to pick up the latest changes, including your work in progress, and test them in a UCinD
cluster: cluster:
+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
+31 -37
View File
@@ -44,20 +44,18 @@ 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.
## 🎬 Quick demo ## 🎬 Quick demo
The screenshot below demonstrates how we use Uncloud to deploy https://uncloud.run website to 2 remote machines from The screenshot below demonstrates how I use Uncloud to deploy https://uncloud.run website to 2 remote machines from
the [`compose.yaml`](website/compose.yaml) file on the local machine. the [`compose.yaml`](website/compose.yaml) file on my local machine.
It exposes the container port `8000/tcp` as HTTPS on `uncloud.run` domain, served by the Caddy reverse proxy on the It exposes the container port `8000/tcp` as HTTPS on the domain `uncloud.run`, served by the Caddy reverse proxy on the
remote machines. All managed by Uncloud. remote machines. All managed by Uncloud.
![Uncloud compose deployment demo](.github/images/website-deploy.jpg) ![Uncloud compose deployment demo](.github/images/compose-deploy.jpg)
Here is a more advanced use case. Deploy a highly available web app with automatic HTTPS across multiple regions and Here is a more advanced use case. Deploy a highly available web app with automatic HTTPS across multiple regions and
on-premises in just a couple minutes. on-premises in just a couple minutes.
@@ -104,9 +102,6 @@ platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bar
See [Installation](https://uncloud.run/docs/getting-started/install-cli) for more options. See [Installation](https://uncloud.run/docs/getting-started/install-cli) for more options.
There's also a [nightly rolling release](https://github.com/psviderski/uncloud/releases/tag/nightly) available if you
want to try the latest features and fixes before they're included in an official release.
2. Initialise your first machine: 2. Initialise your first machine:
```bash ```bash
@@ -156,6 +151,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 +164,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 +187,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 +210,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 +243,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
@@ -285,24 +283,18 @@ machine is a full backup of your control plane.
## 🧪 Interactive tutorials ## 🧪 Interactive tutorials
To give you a chance to play with Uncloud without even leaving your browser, we're providing interactive tutorials and To give you a chance to play with Uncloud without even leaving your browser, we're providing interactive tutorials and playgrounds on the [iximiuz Labs](https://labs.iximiuz.com/) platform.
playgrounds on the [iximiuz Labs](https://labs.iximiuz.com/) platform.
Available tutorials: Available tutorials:
1. [Setting up a new Uncloud cluster](https://labs.iximiuz.com/tutorials/uncloud-create-cluster-ebebf72b) - the tutorial 1. [Setting up a new Uncloud cluster](https://labs.iximiuz.com/tutorials/uncloud-create-cluster-ebebf72b) - the tutorial walks you through creating a new cluster with two machines and then deploying a simple web service to it.
walks you through creating a new cluster with two machines and then deploying a simple web service to it.
2. [Deploying a Django web application](https://labs.iximiuz.com/tutorials/uncloud-deploy-django-app-7a378bc3) - deploy
a Django web app from source code to a remote Linux server using Uncloud.
You can also launch the [Uncloud playground](https://labs.iximiuz.com/playgrounds/uncloud-cluster-64523f7c) where you You can also launch the [Uncloud playground](https://labs.iximiuz.com/playgrounds/uncloud-cluster-64523f7c) where you can play with an already initialized Uncloud cluster.
can play with an already initialized Uncloud cluster.
## 🏗 Project status ## 🏗 Project status
Uncloud is under active development and pre-1.0, so expect occasional breaking changes between releases. The core build, Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
push, and deploy workflows are stable, and much of the runtime relies on battle-tested projects: Docker, Caddy, and there may be breaking changes between releases.
WireGuard, and Corrosion.
We'd love your input! Here's how you can contribute: We'd love your input! Here's how you can contribute:
@@ -325,9 +317,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
@@ -340,8 +332,8 @@ SQLite database used to share Uncloud's cluster state.
* Join our [Discord server](https://discord.gg/eR35KQJhPu) for real-time discussions, support, and updates. * Join our [Discord server](https://discord.gg/eR35KQJhPu) for real-time discussions, support, and updates.
* Follow [@psviderski](https://x.com/psviderski) on X/Twitter. * Follow [@psviderski](https://x.com/psviderski) on X/Twitter.
* Subscribe to [our newsletter](https://uncloud.run/#subscribe) to follow the journey of building Uncloud: progress * Subscribe to [my newsletter](https://uncloud.run/#subscribe) to follow the progress, get early insights into new
updates, user stories, and the occasional deep dive. features, and be the first to know when it's ready for production use.
* Watch this repository for releases. * Watch this repository for releases.
## 💖 Sponsors ## 💖 Sponsors
@@ -359,6 +351,8 @@ These companies and projects are helping Uncloud with their generous sponsorship
<img alt="Trendshift" src="https://trendshift.io/api/badge/repositories/14069" /> <img alt="Trendshift" src="https://trendshift.io/api/badge/repositories/14069" />
</a> </a>
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
<a href="https://github.com/psviderski/uncloud/graphs/contributors"> <a href="https://github.com/psviderski/uncloud/graphs/contributors">
<img src="https://contrib.rocks/image?repo=psviderski/uncloud" /> <img src="https://contrib.rocks/image?repo=psviderski/uncloud" />
</a> </a>
-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",
}
-787
View File
@@ -1,787 +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/common.proto
package pb
import (
status "google.golang.org/genproto/googleapis/rpc/status"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
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 LogEntry_StreamType int32
const (
LogEntry_UNKNOWN LogEntry_StreamType = 0
LogEntry_STDOUT LogEntry_StreamType = 1
LogEntry_STDERR LogEntry_StreamType = 2
LogEntry_HEARTBEAT LogEntry_StreamType = 3
)
// Enum value maps for LogEntry_StreamType.
var (
LogEntry_StreamType_name = map[int32]string{
0: "UNKNOWN",
1: "STDOUT",
2: "STDERR",
3: "HEARTBEAT",
}
LogEntry_StreamType_value = map[string]int32{
"UNKNOWN": 0,
"STDOUT": 1,
"STDERR": 2,
"HEARTBEAT": 3,
}
)
func (x LogEntry_StreamType) Enum() *LogEntry_StreamType {
p := new(LogEntry_StreamType)
*p = x
return p
}
func (x LogEntry_StreamType) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (LogEntry_StreamType) Descriptor() protoreflect.EnumDescriptor {
return file_api_pb_common_proto_enumTypes[0].Descriptor()
}
func (LogEntry_StreamType) Type() protoreflect.EnumType {
return &file_api_pb_common_proto_enumTypes[0]
}
func (x LogEntry_StreamType) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use LogEntry_StreamType.Descriptor instead.
func (LogEntry_StreamType) EnumDescriptor() ([]byte, []int) {
return file_api_pb_common_proto_rawDescGZIP(), []int{7, 0}
}
// Common metadata message nested in all reply message types, injected by the gRPC proxy to provide information
// about the machine that responded to the request.
type Metadata struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// ID of the machine the response came from.
MachineId string `protobuf:"bytes,4,opt,name=machine_id,json=machineId,proto3" json:"machine_id,omitempty"`
// Name of the machine the response came from.
MachineName string `protobuf:"bytes,5,opt,name=machine_name,json=machineName,proto3" json:"machine_name,omitempty"`
// Address of the machine the response came from.
MachineAddr string `protobuf:"bytes,1,opt,name=machine_addr,json=machineAddr,proto3" json:"machine_addr,omitempty"`
// error is set if the request to upstream failed. The rest of the response apart from this metadata message
// is undefined.
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
// error as a gRPC Status message.
Status *status.Status `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
}
func (x *Metadata) Reset() {
*x = Metadata{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_common_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Metadata) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Metadata) ProtoMessage() {}
func (x *Metadata) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_common_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 Metadata.ProtoReflect.Descriptor instead.
func (*Metadata) Descriptor() ([]byte, []int) {
return file_api_pb_common_proto_rawDescGZIP(), []int{0}
}
func (x *Metadata) GetMachineId() string {
if x != nil {
return x.MachineId
}
return ""
}
func (x *Metadata) GetMachineName() string {
if x != nil {
return x.MachineName
}
return ""
}
func (x *Metadata) GetMachineAddr() string {
if x != nil {
return x.MachineAddr
}
return ""
}
func (x *Metadata) GetError() string {
if x != nil {
return x.Error
}
return ""
}
func (x *Metadata) GetStatus() *status.Status {
if x != nil {
return x.Status
}
return nil
}
// A helper message for marshalling the metadata field and injecting it into the response by the gRPC proxy.
type Empty struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Metadata *Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"`
}
func (x *Empty) Reset() {
*x = Empty{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_common_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Empty) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_common_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 Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
return file_api_pb_common_proto_rawDescGZIP(), []int{1}
}
func (x *Empty) GetMetadata() *Metadata {
if x != nil {
return x.Metadata
}
return nil
}
// EmptyResponse is a response message to be returned by the gRPC proxy when a request to the upstream failed.
// The nested Empty.Metadata message should contain an error.
type EmptyResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Messages []*Empty `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"`
}
func (x *EmptyResponse) Reset() {
*x = EmptyResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_common_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *EmptyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EmptyResponse) ProtoMessage() {}
func (x *EmptyResponse) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_common_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 EmptyResponse.ProtoReflect.Descriptor instead.
func (*EmptyResponse) Descriptor() ([]byte, []int) {
return file_api_pb_common_proto_rawDescGZIP(), []int{2}
}
func (x *EmptyResponse) GetMessages() []*Empty {
if x != nil {
return x.Messages
}
return nil
}
type IP struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Ip []byte `protobuf:"bytes,1,opt,name=ip,proto3" json:"ip,omitempty"`
}
func (x *IP) Reset() {
*x = IP{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_common_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *IP) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IP) ProtoMessage() {}
func (x *IP) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_common_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 IP.ProtoReflect.Descriptor instead.
func (*IP) Descriptor() ([]byte, []int) {
return file_api_pb_common_proto_rawDescGZIP(), []int{3}
}
func (x *IP) GetIp() []byte {
if x != nil {
return x.Ip
}
return nil
}
type IPPort struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Ip *IP `protobuf:"bytes,1,opt,name=ip,proto3" json:"ip,omitempty"`
Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"`
}
func (x *IPPort) Reset() {
*x = IPPort{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_common_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *IPPort) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IPPort) ProtoMessage() {}
func (x *IPPort) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_common_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 IPPort.ProtoReflect.Descriptor instead.
func (*IPPort) Descriptor() ([]byte, []int) {
return file_api_pb_common_proto_rawDescGZIP(), []int{4}
}
func (x *IPPort) GetIp() *IP {
if x != nil {
return x.Ip
}
return nil
}
func (x *IPPort) GetPort() uint32 {
if x != nil {
return x.Port
}
return 0
}
type IPPrefix struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Ip *IP `protobuf:"bytes,1,opt,name=ip,proto3" json:"ip,omitempty"`
Bits uint32 `protobuf:"varint,2,opt,name=bits,proto3" json:"bits,omitempty"`
}
func (x *IPPrefix) Reset() {
*x = IPPrefix{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_common_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *IPPrefix) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IPPrefix) ProtoMessage() {}
func (x *IPPrefix) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_common_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 IPPrefix.ProtoReflect.Descriptor instead.
func (*IPPrefix) Descriptor() ([]byte, []int) {
return file_api_pb_common_proto_rawDescGZIP(), []int{5}
}
func (x *IPPrefix) GetIp() *IP {
if x != nil {
return x.Ip
}
return nil
}
func (x *IPPrefix) GetBits() uint32 {
if x != nil {
return x.Bits
}
return 0
}
type LogsRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
// Options for logs retrieval.
Follow bool `protobuf:"varint,2,opt,name=follow,proto3" json:"follow,omitempty"`
Tail int32 `protobuf:"varint,3,opt,name=tail,proto3" json:"tail,omitempty"` // -1 means all
Since string `protobuf:"bytes,4,opt,name=since,proto3" json:"since,omitempty"` // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
Until string `protobuf:"bytes,5,opt,name=until,proto3" json:"until,omitempty"` // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
}
func (x *LogsRequest) Reset() {
*x = LogsRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_common_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *LogsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogsRequest) ProtoMessage() {}
func (x *LogsRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_common_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 LogsRequest.ProtoReflect.Descriptor instead.
func (*LogsRequest) Descriptor() ([]byte, []int) {
return file_api_pb_common_proto_rawDescGZIP(), []int{6}
}
func (x *LogsRequest) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *LogsRequest) GetFollow() bool {
if x != nil {
return x.Follow
}
return false
}
func (x *LogsRequest) GetTail() int32 {
if x != nil {
return x.Tail
}
return 0
}
func (x *LogsRequest) GetSince() string {
if x != nil {
return x.Since
}
return ""
}
func (x *LogsRequest) GetUntil() string {
if x != nil {
return x.Until
}
return ""
}
type LogEntry struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Stream LogEntry_StreamType `protobuf:"varint,1,opt,name=stream,proto3,enum=api.LogEntry_StreamType" json:"stream,omitempty"`
Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
// Log line content. Empty for heartbeat entries.
Message []byte `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
}
func (x *LogEntry) Reset() {
*x = LogEntry{}
if protoimpl.UnsafeEnabled {
mi := &file_api_pb_common_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *LogEntry) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogEntry) ProtoMessage() {}
func (x *LogEntry) ProtoReflect() protoreflect.Message {
mi := &file_api_pb_common_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 LogEntry.ProtoReflect.Descriptor instead.
func (*LogEntry) Descriptor() ([]byte, []int) {
return file_api_pb_common_proto_rawDescGZIP(), []int{7}
}
func (x *LogEntry) GetStream() LogEntry_StreamType {
if x != nil {
return x.Stream
}
return LogEntry_UNKNOWN
}
func (x *LogEntry) GetTimestamp() *timestamppb.Timestamp {
if x != nil {
return x.Timestamp
}
return nil
}
func (x *LogEntry) GetMessage() []byte {
if x != nil {
return x.Message
}
return nil
}
var File_api_pb_common_proto protoreflect.FileDescriptor
var file_api_pb_common_proto_rawDesc = []byte{
0x0a, 0x13, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x1a, 0x17, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x22, 0xb1, 0x01, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18,
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64,
0x12, 0x21, 0x0a, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65,
0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e,
0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x61,
0x64, 0x64, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69,
0x6e, 0x65, 0x41, 0x64, 0x64, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x2a, 0x0a, 0x06,
0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x32, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74,
0x79, 0x12, 0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0x37, 0x0a, 0x0d,
0x45, 0x6d, 0x70, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26, 0x0a,
0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x0a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x14, 0x0a, 0x02, 0x49, 0x50, 0x12, 0x0e, 0x0a, 0x02, 0x69,
0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70, 0x22, 0x35, 0x0a, 0x06, 0x49,
0x50, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12,
0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f,
0x72, 0x74, 0x22, 0x37, 0x0a, 0x08, 0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x17,
0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69,
0x2e, 0x49, 0x50, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x69, 0x74, 0x73, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x62, 0x69, 0x74, 0x73, 0x22, 0x75, 0x0a, 0x0b, 0x4c,
0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f,
0x6c, 0x6c, 0x6f, 0x77, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x6c,
0x6f, 0x77, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05,
0x52, 0x04, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x18,
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05,
0x75, 0x6e, 0x74, 0x69, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x75, 0x6e, 0x74,
0x69, 0x6c, 0x22, 0xd2, 0x01, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
0x30, 0x0a, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32,
0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x2e, 0x53,
0x74, 0x72, 0x65, 0x61, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x52, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61,
0x6d, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,
0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x18, 0x0a, 0x07, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x40, 0x0a, 0x0a, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x54,
0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00,
0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x44, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06,
0x53, 0x54, 0x44, 0x45, 0x52, 0x52, 0x10, 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52,
0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x03, 0x42, 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_common_proto_rawDescOnce sync.Once
file_api_pb_common_proto_rawDescData = file_api_pb_common_proto_rawDesc
)
func file_api_pb_common_proto_rawDescGZIP() []byte {
file_api_pb_common_proto_rawDescOnce.Do(func() {
file_api_pb_common_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_pb_common_proto_rawDescData)
})
return file_api_pb_common_proto_rawDescData
}
var file_api_pb_common_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_api_pb_common_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_api_pb_common_proto_goTypes = []any{
(LogEntry_StreamType)(0), // 0: api.LogEntry.StreamType
(*Metadata)(nil), // 1: api.Metadata
(*Empty)(nil), // 2: api.Empty
(*EmptyResponse)(nil), // 3: api.EmptyResponse
(*IP)(nil), // 4: api.IP
(*IPPort)(nil), // 5: api.IPPort
(*IPPrefix)(nil), // 6: api.IPPrefix
(*LogsRequest)(nil), // 7: api.LogsRequest
(*LogEntry)(nil), // 8: api.LogEntry
(*status.Status)(nil), // 9: google.rpc.Status
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
}
var file_api_pb_common_proto_depIdxs = []int32{
9, // 0: api.Metadata.status:type_name -> google.rpc.Status
1, // 1: api.Empty.metadata:type_name -> api.Metadata
2, // 2: api.EmptyResponse.messages:type_name -> api.Empty
4, // 3: api.IPPort.ip:type_name -> api.IP
4, // 4: api.IPPrefix.ip:type_name -> api.IP
0, // 5: api.LogEntry.stream:type_name -> api.LogEntry.StreamType
10, // 6: api.LogEntry.timestamp:type_name -> google.protobuf.Timestamp
7, // [7:7] is the sub-list for method output_type
7, // [7:7] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_api_pb_common_proto_init() }
func file_api_pb_common_proto_init() {
if File_api_pb_common_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_api_pb_common_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*Metadata); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_common_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*Empty); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_common_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*EmptyResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_common_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*IP); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_common_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*IPPort); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_common_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*IPPrefix); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_common_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*LogsRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_pb_common_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*LogEntry); 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_common_proto_rawDesc,
NumEnums: 1,
NumMessages: 8,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_api_pb_common_proto_goTypes,
DependencyIndexes: file_api_pb_common_proto_depIdxs,
EnumInfos: file_api_pb_common_proto_enumTypes,
MessageInfos: file_api_pb_common_proto_msgTypes,
}.Build()
File_api_pb_common_proto = out.File
file_api_pb_common_proto_rawDesc = nil
file_api_pb_common_proto_goTypes = nil
file_api_pb_common_proto_depIdxs = nil
}
-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)
})
}
}
-27
View File
@@ -1,27 +0,0 @@
package context
import (
"fmt"
"github.com/psviderski/uncloud/internal/cli"
"github.com/spf13/cobra"
)
func NewShowCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "show",
Short: "Show current cluster context.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return show(uncli)
},
}
return cmd
}
func show(uncli *cli.CLI) error {
fmt.Println(uncli.ContextOverrideOrCurrent())
return nil
}
-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)
}
-141
View File
@@ -1,141 +0,0 @@
package machine
import (
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/cli/logs"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra"
)
func NewLogsCommand() *cobra.Command {
var options logs.Options
cmd := &cobra.Command{
Use: "logs [SERVICE...]",
Aliases: []string{"log"},
Short: "View system service logs.",
Long: `View logs from the specified system service(s) across all machines in the cluster.
Use -m to restrict to specific machines.
Supported services:
corrosion the Corrosion distributed state store
docker the Docker daemon
uncloud the Uncloud daemon
If no services are specified, streams logs from the uncloud service.`,
Example: ` # View recent logs for the uncloud service.
uc machine logs
uc machine logs uncloud
# Stream logs in real-time (follow mode).
uc machine logs -f uncloud
# View logs from multiple services.
uc machine logs uncloud docker corrosion
# Show last 20 lines per machine (default is 100).
uc machine logs -n 20 docker
# Show all logs without line limit.
uc machine logs -n all docker
# View logs from a specific time range.
uc machine logs --since 3h --until 1h30m docker
# View logs only from specific machines.
uc machine logs -m machine1,machine2 uncloud corrosion`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return runLogs(cmd.Context(), uncli, args, options)
},
}
cmd.Flags().AddFlagSet(logs.Flags(&options))
completion.MachinesFlag(cmd)
return cmd
}
func runLogs(ctx context.Context, uncli *cli.CLI, services []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
}
if len(services) == 0 {
services = []string{api.SystemServiceUncloud}
}
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, ", "))
}
}
c, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer c.Close()
logsOpts := api.ServiceLogsOptions{
Follow: opts.Follow,
Tail: tail,
Since: since,
Until: until,
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
}
// Resolve machine records for the formatter's column width computation.
machines, err := c.ListMachines(ctx, &api.MachineFilter{
NamesOrIDs: logsOpts.Machines,
})
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machineNames := make([]string, 0, len(machines))
for _, m := range machines {
machineNames = append(machineNames, m.Machine.Name)
}
// Collect one log stream per service. MachineLogs merges across machines internally.
serviceStreams := make([]<-chan api.ServiceLogEntry, 0, len(services))
for _, service := range services {
ch, err := c.MachineLogs(ctx, service, logsOpts)
if err != nil {
return fmt.Errorf("stream logs for system service '%s': %w", service, err)
}
serviceStreams = append(serviceStreams, ch)
}
var stream <-chan api.ServiceLogEntry
if len(serviceStreams) == 1 {
stream = serviceStreams[0]
} else {
// Each MachineLogs stream already runs its own inner merger with stall detection,
// so the outer merger across services skips it to avoid duplicate warnings.
merger := client.NewLogMerger(serviceStreams, client.LogMergerOptions{})
stream = merger.Stream()
}
formatter := logs.NewFormatter(machineNames, services, opts.UTC)
// Print merged logs.
for entry := range stream {
formatter.PrintEntry(entry)
}
return nil
}
-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")
}
}
-118
View File
@@ -1,118 +0,0 @@
package machine
import (
"context"
"fmt"
"sort"
"time"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
func NewRTTCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "rtt",
Short: "Show round-trip times between machines.",
Long: `Show round-trip times between machines.
Round-trip time statistics are collected from the Corrosion gossip protocol
and represent the median of recent RTT samples between each pair of machines
in the cluster. The values shown include the median RTT and standard deviation
for each machine-to-machine connection.`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return rtt(cmd.Context(), uncli)
},
}
return cmd
}
func rtt(ctx context.Context, uncli *cli.CLI) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer client.Close()
// Setup context to proxy request to all machines.
ctx = client.ProxyMachinesContext(ctx, nil)
resp, err := client.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
if err != nil {
return fmt.Errorf("inspect machines: %w", err)
}
// Map machine IDs to names for display from the response.
machineNames := make(map[string]string)
for _, m := range resp.Machines {
// NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed.
if m.Metadata == nil {
tui.PrintWarning("metadata is missing in response from unknown server")
continue
}
if 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 {
continue
}
machineNames[m.Machine.Id] = m.Machine.Name
}
type row struct {
machine string
peer string
median time.Duration
stdDev time.Duration
}
var rows []row
for _, m := range resp.Machines {
// Unlikely to occur, but might be a possible edge case when
// a machine is still initializing. So just to be safe.
if m.Machine == nil || m.Rtts == nil {
continue
}
for peerID, stats := range m.Rtts {
peerName := peerID
if name, ok := machineNames[peerID]; ok {
peerName = name
}
rows = append(rows, row{
machine: m.Machine.Name,
peer: peerName,
median: stats.Median.AsDuration(),
stdDev: stats.StdDev.AsDuration(),
})
}
}
// Sort by machine name then peer name.
sort.Slice(rows, func(i, j int) bool {
if rows[i].machine == rows[j].machine {
return rows[i].peer < rows[j].peer
}
return rows[i].machine < rows[j].machine
})
// Print table.
t := tui.NewTable()
t.Headers("MACHINE", "PEER", "MEDIAN", "STDDEV")
for _, r := range rows {
t.Row(r.machine, r.peer, tui.FormatRTT(r.median), formatRTTStdDev(r.stdDev))
}
lipgloss.Println(t)
return nil
}
// formatRTTStdDev formats a round-trip time standard deviation with one decimal place, e.g. "±19.4ms".
func formatRTTStdDev(d time.Duration) string {
return fmt.Sprintf("±%.1fms", float64(d)/float64(time.Millisecond))
}
-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)
}
}
}
-61
View File
@@ -1,61 +0,0 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeExecArgs(t *testing.T) {
tests := []struct {
name string
args []string
wantServiceName string
wantCommand []string
}{
{
name: "service only",
args: []string{"test-service"},
wantServiceName: "test-service",
wantCommand: []string{},
},
{
name: "service with command",
args: []string{"test-service", "echo", "hello"},
wantServiceName: "test-service",
wantCommand: []string{"echo", "hello"},
},
{
name: "service with separator and command",
args: []string{"test-service", "--", "echo", "hello"},
wantServiceName: "test-service",
wantCommand: []string{"echo", "hello"},
},
{
name: "service with separator only",
args: []string{"test-service", "--"},
wantServiceName: "test-service",
wantCommand: []string{},
},
{
name: "separator preserves command flag",
args: []string{"test-service", "--", "--help"},
wantServiceName: "test-service",
wantCommand: []string{"--help"},
},
{
name: "only first separator is removed",
args: []string{"test-service", "--", "cmd", "--", "arg"},
wantServiceName: "test-service",
wantCommand: []string{"cmd", "--", "arg"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotServiceName, gotCommand := normalizeExecArgs(tt.args)
assert.Equal(t, tt.wantServiceName, gotServiceName)
assert.Equal(t, tt.wantCommand, gotCommand)
})
}
}
-150
View File
@@ -1,150 +0,0 @@
package service
import (
"context"
"fmt"
"maps"
"slices"
"time"
"charm.land/lipgloss/v2"
"github.com/docker/docker/pkg/stringid"
"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/pkg/api"
"github.com/spf13/cobra"
)
type inspectOptions struct {
service string
}
func NewInspectCommand(groupID string) *cobra.Command {
opts := inspectOptions{}
cmd := &cobra.Command{
Use: "inspect SERVICE",
Short: "Display detailed information on a service.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0]
return inspect(cmd.Context(), uncli, opts)
},
GroupID: groupID,
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 inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer client.Close()
svc, err := client.InspectService(ctx, opts.service)
if err != nil {
return fmt.Errorf("inspect service: %w", err)
}
fmt.Printf("Service ID: %s\n", svc.ID)
fmt.Printf("Name: %s\n", svc.Name)
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()
// Combine regular and hook containers.
allContainers := append(svc.Containers, svc.HookContainers...)
// Parse created times for sorting and display.
createdTimes := make(map[string]time.Time, len(allContainers))
for _, ctr := range allContainers {
createdTimes[ctr.Container.ID], _ = time.Parse(time.RFC3339Nano, ctr.Container.Created)
}
// Sort containers by created time (newest first).
slices.SortFunc(allContainers, func(a, b api.MachineServiceContainer) int {
return createdTimes[b.Container.ID].Compare(createdTimes[a.Container.ID])
})
// Print the list of containers in a table format.
// Show HOOK column only when hook containers are present.
hasHooks := len(svc.HookContainers) > 0
t := tui.NewTable()
if hasHooks {
t.Headers("CONTAINER ID", "IMAGE", "CREATED", "STATUS", "HOOK", "IP ADDRESS", "MACHINE")
} else {
t.Headers("CONTAINER ID", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
}
now := time.Now().UTC()
for _, ctr := range allContainers {
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
machine := ctr.MachineName
if machine == "" {
machine = ctr.MachineID
}
state, err := ctr.Container.HumanState()
if err != nil {
return fmt.Errorf("get human state: %w", err)
}
ip := ctr.Container.UncloudNetworkIP()
ipStr := ""
// The container might not have an IP if it's not running or uses the host network.
if ip.IsValid() {
ipStr = ip.String()
}
if hasHooks {
t.Row(
stringid.TruncateID(ctr.Container.ID),
tui.FormatImage(ctr.Container.Config.Image, tui.NoStyle),
created,
state,
ctr.Container.Config.Labels[api.LabelHook],
ipStr,
machine,
)
} else {
t.Row(
stringid.TruncateID(ctr.Container.ID),
tui.FormatImage(ctr.Container.Config.Image, tui.NoStyle),
created,
state,
ipStr,
machine,
)
}
}
lipgloss.Println(t)
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])
}
}
-198
View File
@@ -1,198 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
mapset "github.com/deckarep/golang-set/v2"
"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/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose"
"github.com/spf13/cobra"
)
func NewLogsCommand(groupID string) *cobra.Command {
var options logs.Options
cmd := &cobra.Command{
Use: "logs [SERVICE[/CONTAINER]...]",
Aliases: []string{"log"},
Short: "View service logs.",
Long: `View logs from all replicas of the specified service(s) across all machines in the cluster.
To view logs from specific replicas (containers) within a service, use the SERVICE/CONTAINER form,
where CONTAINER is a container name, full ID, or unique ID prefix.
If no services are specified, streams logs from all services defined in the Compose file
(compose.yaml by default or the file(s) specified with --file).`,
Example: ` # View recent logs for a service.
uc logs web
# Stream logs in real-time (follow mode).
uc logs -f web
# View logs from multiple services.
uc logs web api db
# View logs from all services in compose.yaml.
uc logs
# Show last 20 lines per replica (default is 100).
uc logs -n 20 web
# Show all logs without line limit.
uc logs -n all web
# View logs from a specific time range.
uc logs --since 3h --until 1h30m web
# View logs only from specific replicas (containers).
uc logs web/61d57fd3428f api/2f60
# View logs only from replicas running on specific machines.
uc logs -m machine1,machine2 web api`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return RunLogs(cmd.Context(), uncli, args, options)
},
GroupID: groupID,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return completion.Services(cmd.Context(), uncli, args, toComplete)
},
}
cmd.Flags().StringSliceVar(&options.Files, "file", nil,
"One or more Compose files to load service names from when no services are specified. (default compose.yaml)")
cmd.Flags().AddFlagSet(logs.Flags(&options))
completion.MachinesFlag(cmd)
return cmd
}
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)
if err != nil {
return err
}
// If no services specified, try to load them from the Compose file(s).
fromCompose := false
if len(serviceArgs) == 0 {
fromCompose = true
project, err := compose.LoadProject(ctx, opts.Files)
if err != nil {
return fmt.Errorf("load Compose file(s): %w", err)
}
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
// View logs for all services, including disabled by inactive profiles.
composeServices := append(project.ServiceNames(), project.DisabledServiceNames()...)
if len(composeServices) == 0 {
return errors.New("no services found in Compose file(s)")
}
serviceArgs = make([]logs.ServiceArg, len(composeServices))
for i, name := range composeServices {
serviceArgs[i] = logs.ServiceArg{Service: name}
}
}
c, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer c.Close()
baseOpts := api.ServiceLogsOptions{
Follow: opts.Follow,
Tail: tail,
Since: since,
Until: until,
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
}
// Collect log streams from all services. When service names come from a Compose file,
// skip the ones that are not found in the cluster (they may have been removed or not deployed yet).
machineIDsSet := mapset.NewSet[string]()
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceArgs))
var foundServices, notFoundServices []string
for _, sa := range serviceArgs {
svcOpts := baseOpts
svcOpts.Containers = sa.Containers
svc, ch, err := c.ServiceLogs(ctx, sa.Service, svcOpts)
if err != nil {
if errors.Is(err, api.ErrNotFound) && fromCompose {
notFoundServices = append(notFoundServices, sa.Service)
continue
}
return fmt.Errorf("stream logs for service '%s': %w", sa.Service, err)
}
svcStreams = append(svcStreams, ch)
foundServices = append(foundServices, sa.Service)
machineIDs := svc.MachineIDs()
machineIDsSet.Append(machineIDs...)
}
if fromCompose {
if len(foundServices) == 0 {
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
strings.Join(opts.Files, ", "))
}
for _, name := range notFoundServices {
tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
}
}
var stream <-chan api.ServiceLogEntry
if len(svcStreams) == 1 {
stream = svcStreams[0]
} else {
// Merge all service streams into a single sorted stream without stall detection as its handled per-service.
merger := client.NewLogMerger(svcStreams, client.LogMergerOptions{})
stream = merger.Stream()
}
// Fetch machine names for all machines (machineIDsSet) service containers are running on.
// Note: this is the full set per service, not narrowed by --machine or per-container filters,
// so the formatter may pad columns wider than strictly needed when filters are active.
machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: machineIDsSet.ToSlice()})
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machineNames := make([]string, 0, len(machines))
for _, m := range machines {
machineNames = append(machineNames, m.Machine.Name)
}
formatter := logs.NewFormatter(machineNames, foundServices, opts.UTC)
// Print merged logs.
for entry := range stream {
formatter.PrintEntry(entry)
}
return nil
}
-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
}
-40
View File
@@ -1,40 +0,0 @@
package cluster
import (
"fmt"
"strings"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/ucind"
"github.com/spf13/cobra"
)
func NewListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List clusters.",
RunE: func(cmd *cobra.Command, args []string) error {
p := cmd.Context().Value("provisioner").(*ucind.Provisioner)
clusters, err := p.ListClusters(cmd.Context())
if err != nil {
return fmt.Errorf("list clusters: %w", err)
}
t := tui.NewTable()
t.Headers("NAME", "MACHINES")
for _, cluster := range clusters {
machines := []string{}
for _, m := range cluster.Machines {
machines = append(machines, m.Name)
}
t.Row(
cluster.Name,
strings.Join(machines, tui.Faint.Render(", ")),
)
}
fmt.Println(t)
return nil
},
}
return cmd
}
-8
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"github.com/psviderski/uncloud/internal/ucind" "github.com/psviderski/uncloud/internal/ucind"
"github.com/psviderski/uncloud/internal/ucind/completion"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -12,7 +11,6 @@ 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)
@@ -28,12 +26,6 @@ func NewRemoveCommand() *cobra.Command {
fmt.Printf("Cluster '%s' removed.\n", name) fmt.Printf("Cluster '%s' removed.\n", name)
return nil return nil
}, },
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return completion.Clusters(cmd, args, toComplete)
},
} }
return cmd return cmd
} }
+1 -1
View File
@@ -13,7 +13,7 @@ func NewRootCommand() *cobra.Command {
} }
cmd.AddCommand( cmd.AddCommand(
NewCreateCommand(), NewCreateCommand(),
NewListCommand(), // NewListCommand(),
NewRemoveCommand(), NewRemoveCommand(),
) )
return cmd return cmd
+2 -4
View File
@@ -8,7 +8,6 @@ import (
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/psviderski/uncloud/cmd/ucind/cluster" "github.com/psviderski/uncloud/cmd/ucind/cluster"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/ucind" "github.com/psviderski/uncloud/internal/ucind"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -24,8 +23,6 @@ func main() {
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil { if err != nil {
return fmt.Errorf("create Docker client: %w", err) return fmt.Errorf("create Docker client: %w", err)
@@ -47,8 +44,9 @@ func main() {
}, },
} }
// TODO: allow to override using UNCLOUD_CONFIG env var.
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.yaml", cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]") "path to the Uncloud configuration file.")
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml") _ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
cmd.AddCommand( cmd.AddCommand(
-6
View File
@@ -6,7 +6,6 @@ import (
composecli "github.com/compose-spec/compose-go/v2/cli" composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/pkg/client/compose" "github.com/psviderski/uncloud/pkg/client/compose"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -51,9 +50,6 @@ to cluster machines or --push-registry to upload them to external registries.`,
return runBuild(cmd.Context(), uncli, opts) return runBuild(cmd.Context(), uncli, opts)
}, },
GroupID: "service", GroupID: "service",
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
return completion.ComposeServices(cmd.Context(), args, toComplete, opts.files, opts.profiles)
},
} }
cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil, cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil,
@@ -80,8 +76,6 @@ to cluster machines or --push-registry to upload them to external registries.`,
cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false, cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false,
"Upload the built images to external registries (e.g., Docker Hub) after building.") "Upload the built images to external registries (e.g., Docker Hub) after building.")
completion.MachinesFlag(cmd)
return cmd return cmd
} }
@@ -3,18 +3,16 @@ 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/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,8 +30,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,
completion.MachinesFlag(cmd) "Disable syntax highlighting for the output.")
return cmd return cmd
} }
@@ -45,20 +43,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 {
fmt.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 highlighting fails, fall back to plain output.
lipgloss.Print(config.Caddyfile) fmt.Print(config.Caddyfile)
} }
if config.LastReconciliationError != "" {
tui.PrintWarning(fmt.Sprintf("last Caddy config load failed: %s\nShowing the last saved Caddyfile.",
config.LastReconciliationError))
} }
return nil return nil
+240
View File
@@ -0,0 +1,240 @@
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/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)")
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, lipgloss.NewStyle())
}
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], lipgloss.NewStyle()))
}
}
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 {
fmt.Println("Cancelled. No changes were made.")
return nil
}
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)
@@ -3,11 +3,11 @@ package context
import ( import (
"fmt" "fmt"
"maps" "maps"
"os"
"slices" "slices"
"text/tabwriter"
"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/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -16,7 +16,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)
@@ -39,8 +38,8 @@ func list(uncli *cli.CLI) error {
contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts)) contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
currentContext := uncli.Config.CurrentContext currentContext := uncli.Config.CurrentContext
t := tui.NewTable() tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
t.Headers("NAME", "CURRENT", "CONNECTIONS") fmt.Fprintln(tw, "NAME\tCURRENT\tCONNECTIONS")
for _, name := range contextNames { for _, name := range contextNames {
current := "" current := ""
@@ -48,9 +47,8 @@ func list(uncli *cli.CLI) error {
current = "✓" current = "✓"
} }
connCount := len(uncli.Config.Contexts[name].Connections) connCount := len(uncli.Config.Contexts[name].Connections)
t.Row(name, current, fmt.Sprintf("%d", connCount)) fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
} }
lipgloss.Println(t) return tw.Flush()
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)
@@ -21,7 +20,6 @@ func NewRootCommand() *cobra.Command {
NewListCommand(), NewListCommand(),
NewUseCommand(), NewUseCommand(),
NewConnectionCommand(), NewConnectionCommand(),
NewShowCommand(),
) )
return cmd return cmd
@@ -7,8 +7,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/tui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -18,7 +16,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)
@@ -32,13 +29,6 @@ func NewUseCommand() *cobra.Command {
return selectContext(uncli) return selectContext(uncli)
}, },
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.Contexts(cmd.Context(), uncli, args, toComplete)
},
} }
return cmd return cmd
@@ -51,10 +41,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))
+4 -85
View File
@@ -9,16 +9,11 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
composecli "github.com/compose-spec/compose-go/v2/cli" composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/pkg/stringid"
"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/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"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/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -49,9 +44,6 @@ func NewDeployCommand() *cobra.Command {
return runDeploy(cmd.Context(), uncli, opts) return runDeploy(cmd.Context(), uncli, opts)
}, },
GroupID: "service", GroupID: "service",
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
return completion.ComposeServices(cmd.Context(), args, toComplete, opts.files, opts.profiles)
},
} }
cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil, cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil,
@@ -163,14 +155,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 +195,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")
} }
@@ -230,7 +214,8 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
return fmt.Errorf("confirm deployment: %w", err) return fmt.Errorf("confirm deployment: %w", err)
} }
if !confirmed { if !confirmed {
return cli.Cancelled("Deploy cancelled. No changes were made.") fmt.Println("Cancelled. No changes were made.")
return nil
} }
} }
@@ -238,76 +223,10 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
if deployTarget != "" { if deployTarget != "" {
title += " to " + tui.NameStyle.Render(deployTarget) title += " to " + tui.NameStyle.Render(deployTarget)
} }
err = progress.RunWithTitle(ctx, func(ctx context.Context) error { return progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err := plan.Execute(ctx, clusterClient); err != nil { if err := plan.Execute(ctx, clusterClient); err != nil {
return fmt.Errorf("deploy services: %w", err) return fmt.Errorf("deploy services: %w", err)
} }
return nil return nil
}, uncli.ProgressOut(), title) }, uncli.ProgressOut(), title)
if err != nil {
fmt.Println()
tail := failedContainerLogsTail()
if hookErr, ok := errors.AsType[*operation.PreDeployHookError](err); ok {
printFailedContainerLogs(ctx, clusterClient,
hookErr.ServiceName, hookErr.ContainerID, hookErr.MachineName, tail,
fmt.Sprintf("Last %d log lines from failed pre-deploy hook:", tail))
fmt.Println()
} else if startErr, ok := errors.AsType[*operation.ContainerHealthError](err); ok {
printFailedContainerLogs(ctx, clusterClient,
startErr.ServiceName, startErr.ContainerID, startErr.MachineName, tail,
fmt.Sprintf("Last %d log lines from failed container:", tail))
fmt.Println()
}
return err
}
return nil
}
// printFailedContainerLogs fetches the last tail log lines from a container that failed during deployment and prints
// them using the standard log formatter under the provided header.
func printFailedContainerLogs(
ctx context.Context, cli *client.Client, serviceName, containerID, machineName string, tail int, header string,
) {
_, ch, err := cli.ServiceLogs(ctx, serviceName, api.ServiceLogsOptions{
Containers: []string{containerID},
Machines: []string{machineName},
Tail: tail,
})
if err != nil {
shortCtrID := stringid.TruncateID(containerID)
fmt.Fprintf(os.Stderr, "Failed to fetch container logs '%s/%s': %v\n", serviceName, shortCtrID, err)
fmt.Fprintf(os.Stderr, "You can try manually with: uc logs %s/%s\n", serviceName, shortCtrID)
return
}
fmt.Println(tui.BoldRed.Render(header))
logsEmpty := true
formatter := logs.NewFormatter([]string{machineName}, []string{serviceName}, false)
for entry := range ch {
logsEmpty = false
formatter.PrintEntry(entry)
}
if logsEmpty {
fmt.Println("<no logs available>")
}
}
// defaultFailedContainerLogsTail is the default number of recent log lines to print from a failed container to give
// the user immediate context without requiring a follow-up 'uc logs' invocation.
// Overridable via UNCLOUD_FAILED_CONTAINER_LOGS_TAIL.
const defaultFailedContainerLogsTail = 10
// failedContainerLogsTail returns the number of log lines to fetch from a failed container, honouring the
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
func failedContainerLogsTail() int {
if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" {
if tail, err := logs.ParseTail(v); err == nil && (tail == -1 || tail > 0) {
return tail
}
}
return defaultFailedContainerLogsTail
} }
@@ -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
} }
-29
View File
@@ -13,10 +13,6 @@ import (
const docsDir = "website/docs/9-cli-reference" const docsDir = "website/docs/9-cli-reference"
type docOptions struct {
manual bool
}
type cmdWrapper struct { type cmdWrapper struct {
cmd *cobra.Command cmd *cobra.Command
} }
@@ -24,7 +20,6 @@ type cmdWrapper struct {
// NewDocsCommand creates a new hidden command to generate CLI reference docs. // NewDocsCommand creates a new hidden command to generate CLI reference docs.
func NewDocsCommand() *cobra.Command { func NewDocsCommand() *cobra.Command {
wrapper := &cmdWrapper{} wrapper := &cmdWrapper{}
opts := docOptions{}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "docs", Use: "docs",
Short: "Generate Uncloud CLI reference docs", Short: "Generate Uncloud CLI reference docs",
@@ -34,28 +29,6 @@ func NewDocsCommand() *cobra.Command {
Args: cobra.NoArgs, Args: cobra.NoArgs,
ValidArgsFunction: cobra.NoFileCompletions, ValidArgsFunction: cobra.NoFileCompletions,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
if opts.manual {
header := &doc.GenManHeader{
Title: "Uncloud",
Section: "1",
Source: "Uncloud https://uncloud.run",
}
if err := doc.GenManTree(cmd.Root(), header, "."); err != nil {
return fmt.Errorf("generate CLI manual pages: %w", err)
}
completionFiles, err := filepath.Glob("uc-completion*.1")
if err != nil {
return fmt.Errorf("list generated manual pages: %w", err)
}
for _, f := range completionFiles {
if err = os.Remove(f); err != nil {
return fmt.Errorf("remove '%s': %w", f, err)
}
}
return nil
}
// Remove existing markdown files. // Remove existing markdown files.
mdFiles, err := filepath.Glob(filepath.Join(docsDir, "*.md")) mdFiles, err := filepath.Glob(filepath.Join(docsDir, "*.md"))
if err != nil { if err != nil {
@@ -99,8 +72,6 @@ func NewDocsCommand() *cobra.Command {
return nil return nil
}, },
} }
cmd.Flags().BoolVar(&opts.manual, "manual", false,
"Generate Uncloud manual pages in the current directory.")
wrapper.cmd = cmd wrapper.cmd = cmd
return cmd return cmd
+80 -72
View File
@@ -10,15 +10,13 @@ import (
"time" "time"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"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"
"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/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -64,8 +62,6 @@ func NewListCommand() *cobra.Command {
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+ "Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
"(default is include all machines)") "(default is include all machines)")
completion.MachinesFlag(cmd)
return cmd return cmd
} }
@@ -89,17 +85,25 @@ 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 {
var err error
clusterImages, err = clusterClient.ListImages(ctx, api.ImageFilter{
Machines: machines, Machines: machines,
Name: opts.nameFilter, 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 +112,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 +186,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
} }
@@ -259,15 +260,22 @@ func formatImageTable(rows []imageRow) string {
columns[5].hide = true columns[5].hide = true
} }
// Hide the "STORE" column if all machines use the containerd store. t := table.New().
hasNonContainerd := slices.ContainsFunc(rows, func(r imageRow) bool { // Remove the default border.
return r.store != "containerd" Border(lipgloss.Border{}).
}) BorderTop(false).
if !hasNonContainerd { BorderBottom(false).
columns[6].hide = true BorderLeft(false).
BorderRight(false).
BorderHeader(false).
BorderColumn(false).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
} }
// Regular style for data rows with padding.
t := tui.NewTable() return lipgloss.NewStyle().PaddingRight(3)
})
var headers []string var headers []string
for _, col := range columns { for _, col := range columns {
@@ -280,7 +288,7 @@ func formatImageTable(rows []imageRow) string {
for _, row := range rows { for _, row := range rows {
values := []string{ values := []string{
row.id, row.id,
tui.FormatImage(row.name, tui.NoStyle), row.name,
row.platforms, row.platforms,
row.createdHuman, row.createdHuman,
row.size, row.size,
@@ -7,7 +7,6 @@ import (
"github.com/containerd/platforms" "github.com/containerd/platforms"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -55,8 +54,6 @@ The image is uploaded to all cluster machines (default) or the specified machine
"Local Docker must be configured to use containerd image store to support multi-platform images.", "Local Docker must be configured to use containerd image store to support multi-platform images.",
) )
completion.MachinesFlag(cmd)
return cmd return cmd
} }
@@ -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,25 +39,18 @@ 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 built-in SSH library (default, no prefix required)
ssh+go://user@host - Use Go's built-in SSH library`, ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
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 if SSH CLI needs to be used and strip scheme
destination := args[0] destination := args[0]
useSSHGo := strings.HasPrefix(destination, "ssh+go://") useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh+go://")
destination = strings.TrimPrefix(destination, "ssh+cli://") destination = strings.TrimPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh://") destination = strings.TrimPrefix(destination, "ssh://")
@@ -71,21 +63,20 @@ Connection methods:
Host: host, Host: host,
Port: port, Port: port,
KeyPath: opts.sshKey, KeyPath: opts.sshKey,
UseSSHGo: useSSHGo, UseSSHCLI: useSSHCLI,
} }
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,24 +91,15 @@ 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 in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
"WireGuard connections\n"+ "Default port %d is used if omitted.\n", network.WireGuardPort)+
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+ "Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is the value of --wg-port if omitted.\n"+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+ "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.", "If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
)
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"+
@@ -141,26 +123,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 +152,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().
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) 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 +186,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 {
@@ -229,7 +230,8 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
return fmt.Errorf("confirm deployment: %w", err) return fmt.Errorf("confirm deployment: %w", err)
} }
if !confirmed { if !confirmed {
return cli.Cancelled("Caddy deploy cancelled. The machine has been added to the cluster.") fmt.Println("Cancelled. No changes were made.")
return 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,12 +44,9 @@ 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 built-in SSH library (default, no prefix required)
ssh+go://user@host - Use Go's built-in SSH library`, ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
Example: ` # Initialise a new cluster with default settings. Example: ` # Initialise a new cluster with default settings.
uc machine init root@<your-server-ip> uc machine init root@<your-server-ip>
@@ -65,20 +61,16 @@ 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
if len(args) > 0 { if len(args) > 0 {
// Determine connection mode and strip scheme. // Determine if SSH CLI is requested and strip scheme
destination := args[0] destination := args[0]
useSSHGo := strings.HasPrefix(destination, "ssh+go://") useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh+go://")
destination = strings.TrimPrefix(destination, "ssh+cli://") destination = strings.TrimPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh://") destination = strings.TrimPrefix(destination, "ssh://")
@@ -91,7 +83,7 @@ Connection methods:
Host: host, Host: host,
Port: port, Port: port,
KeyPath: opts.sshKey, KeyPath: opts.sshKey,
UseSSHGo: useSSHGo, UseSSHCLI: useSSHCLI,
} }
} }
@@ -107,7 +99,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 +115,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,24 +130,15 @@ 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 in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
"WireGuard connections\n"+ "Default port %d is used if omitted.\n", network.WireGuardPort)+
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+ "Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is the value of --wg-port if omitted.\n"+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+ "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.", "If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
)
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"+
@@ -166,13 +149,11 @@ Connection methods:
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error { func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
if uncli.Config == nil { if uncli.Config == nil {
// Config is nil when connecting directly to a remote machine (--connect) without using Uncloud config // Config is nil when connecting directly to a remote machine (--connect) without using Uncloud config.
// or when being logged in on a machine and using the uncloud socket directly.
return fmt.Errorf( return fmt.Errorf(
"do not use --connect when initialising a new cluster: --connect is for overriding the connection " + "do not use --connect when initialising a new cluster: --connect is for overriding the connection " +
"to an existing cluster, but 'machine init' creates a new one and writes the new cluster context " + "to an existing cluster, but 'machine init' creates a new one and writes the new cluster context " +
"to the Uncloud config file (--uncloud-config), when logged in on a cluster machine, 'machine init' " + "to the Uncloud config file (--uncloud-config)")
"is not supported")
} }
netPrefix, err := netip.ParsePrefix(opts.network) netPrefix, err := netip.ParsePrefix(opts.network)
@@ -193,13 +174,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 +182,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 +227,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 +235,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 +251,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
@@ -2,42 +2,31 @@ package machine
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/netip" "net/netip"
"os"
"strings" "strings"
"text/tabwriter"
"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/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
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,20 +38,13 @@ 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() tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
t.Headers("NAME", "STATE", "ADDRESS", "PUBLIC IP", "WIREGUARD ENDPOINTS", // Print header.
"OS", "KERNEL", "ARCH", "DOCKER", "VERSION") if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS\tMACHINE ID"); err != nil {
return fmt.Errorf("write header: %w", err)
}
// Print rows.
for _, member := range machines { for _, member := range machines {
m := member.Machine m := member.Machine
subnet, _ := m.Network.Subnet.ToPrefix() subnet, _ := m.Network.Subnet.ToPrefix()
@@ -80,47 +62,14 @@ func list(ctx context.Context, uncli *cli.CLI, output string) error {
endpoints[i] = addrPort.String() endpoints[i] = addrPort.String()
} }
arch := "-" if _, err = fmt.Fprintf(
if m.Arch != "" { tw, "%s\t%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
arch = m.Arch strings.Join(endpoints, ", "), member.Machine.Id,
); err != nil {
return fmt.Errorf("write row: %w", err)
} }
osName := "-"
if m.OsPrettyName != "" {
osName = m.OsPrettyName
} }
return tw.Flush()
kernel := "-"
if m.KernelVersion != "" {
kernel = m.KernelVersion
}
daemonVersion := "-"
if m.DaemonVersion != "" {
daemonVersion = m.DaemonVersion
}
dockerVersion := "-"
if m.DockerVersion != "" {
dockerVersion = m.DockerVersion
}
t.Row(
m.Name,
capitalise(member.State.String()),
subnet.String(),
publicIP,
strings.Join(endpoints, tui.Faint.Render(", ")),
osName,
kernel,
arch,
dockerVersion,
daemonVersion,
)
}
lipgloss.Println(t)
return nil
} }
// capitalise returns a string where the first character is upper case, and the rest is lower case. // capitalise returns a string where the first character is upper case, and the rest is lower case.
@@ -13,10 +13,9 @@ 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/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"
) )
@@ -38,13 +37,6 @@ func NewRmCommand() *cobra.Command {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return remove(cmd.Context(), uncli, args[0], opts) return remove(cmd.Context(), uncli, args[0], 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.Machines(cmd.Context(), uncli, args, toComplete)
},
} }
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
@@ -63,16 +55,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)
@@ -85,12 +76,9 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
return fmt.Errorf("list machines: %w", err) return fmt.Errorf("list machines: %w", err)
} }
if len(allMachines) > 1 { if len(allMachines) > 1 {
return fmt.Errorf("cannot remove the machine you are currently connected to. "+ return errors.New("cannot remove the machine you are currently connected to. " +
"Please connect to another machine in the cluster and try again. "+ "Please connect to another machine in the cluster and try again. " +
"Change the default connection for the current cluster using 'uc ctx conn' or "+ "Use --connect flag or update 'connections' for the cluster context in your Uncloud config")
"manually update 'connections' in your Uncloud config (%s). "+
"For more information on connecting to a cluster, see "+
"https://uncloud.run/docs/concepts/clusters/connecting/", uncli.Config.Path())
// It's ok to remove the proxy machine if it's the last one in the cluster. // It's ok to remove the proxy machine if it's the last one in the cluster.
} }
} }
@@ -104,7 +92,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 +102,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 +124,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 +139,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()
@@ -219,7 +206,7 @@ func formatContainerTree(containers []api.ServiceContainer) string {
// Add containers as children. // Add containers as children.
for _, ctr := range ctrs { for _, ctr := range ctrs {
state, _ := ctr.HumanState() state, _ := ctr.HumanState()
info := fmt.Sprintf("%s • %s • %s", ctr.Name, tui.FormatImage(ctr.Config.Image, tui.NoStyle), state) info := fmt.Sprintf("%s • %s • %s", ctr.Name, ctr.Config.Image, state)
t.Child(info) t.Child(info)
} }
@@ -14,10 +14,8 @@ func NewRootCommand() *cobra.Command {
NewAddCommand(), NewAddCommand(),
NewInitCommand(), NewInitCommand(),
NewListCommand(), NewListCommand(),
NewLogsCommand(),
NewRenameCommand(), NewRenameCommand(),
NewRmCommand(), NewRmCommand(),
NewRTTCommand(),
NewUpdateCommand(), NewUpdateCommand(),
) )
return cmd return cmd
@@ -6,9 +6,8 @@ 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/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"
) )
@@ -47,13 +46,6 @@ At least one flag must be specified to perform an update.`,
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return update(cmd.Context(), uncli, cmd, opts, args[0]) return update(cmd.Context(), uncli, cmd, opts, args[0])
}, },
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.Machines(cmd.Context(), uncli, args, toComplete)
},
} }
cmd.Flags().StringVar( cmd.Flags().StringVar(
@@ -67,11 +59,9 @@ At least one flag must be specified to perform an update.`,
) )
cmd.Flags().StringSliceVar( cmd.Flags().StringSliceVar(
&opts.wgEndpoints, "wg-endpoint", nil, &opts.wgEndpoints, "wg-endpoint", nil,
fmt.Sprintf("WireGuard endpoint address that other machines in the cluster should use to establish "+ fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
"WireGuard connections\n"+ "Default port %d is used if omitted.\n", network.WireGuardPort)+
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+ "Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is %d if omitted.\n",
network.DefaultWireGuardPort)+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.", "Multiple endpoints can be specified by repeating the flag or using a comma-separated list.",
) )
@@ -90,13 +80,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 +112,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 +122,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)
} }
+24 -77
View File
@@ -2,29 +2,25 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/netip" "net/netip"
"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" "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/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,45 +36,34 @@ 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")
var conn *config.MachineConnection var conn *config.MachineConnection
if opts.connect != "" { if opts.connect != "" {
if after, ok := strings.CutPrefix(opts.connect, "tcp://"); ok { if strings.HasPrefix(opts.connect, "tcp://") {
addrPort, err := netip.ParseAddrPort(after) addrPort, err := netip.ParseAddrPort(opts.connect[len("tcp://"):])
if err != nil { if err != nil {
return fmt.Errorf("parse TCP address: %w", err) return fmt.Errorf("parse TCP address: %w", err)
} }
conn = &config.MachineConnection{ conn = &config.MachineConnection{
TCP: &addrPort, TCP: &addrPort,
} }
} else if after, ok := strings.CutPrefix(opts.connect, "ssh+go://"); ok { } else if strings.HasPrefix(opts.connect, "ssh+cli://") {
dest := opts.connect[len("ssh+cli://"):]
conn = &config.MachineConnection{ conn = &config.MachineConnection{
SSHGo: config.SSHDestination(after), SSHCLI: config.SSHDestination(dest),
}
} else if after, ok := strings.CutPrefix(opts.connect, "ssh+cli://"); ok {
// Backward-compatible alias for ssh://.
conn = &config.MachineConnection{
SSH: config.SSHDestination(after),
} }
} else if strings.HasPrefix(opts.connect, "unix://") { } else if strings.HasPrefix(opts.connect, "unix://") {
conn = &config.MachineConnection{ conn = &config.MachineConnection{
Unix: opts.connect[len("unix://"):], Unix: opts.connect[len("unix://"):],
} }
} else { } else {
// Default: system ssh CLI command (no prefix or ssh:// prefix).
dest := strings.TrimPrefix(opts.connect, "ssh://") dest := strings.TrimPrefix(opts.connect, "ssh://")
conn = &config.MachineConnection{ conn = &config.MachineConnection{
SSH: config.SSHDestination(dest), SSH: config.SSHDestination(dest),
@@ -87,16 +72,6 @@ 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 !fs.Exists(configPath) && fs.Exists(machine.DefaultClusterAPISockPath) {
conn = &config.MachineConnection{
Unix: machine.DefaultClusterAPISockPath,
}
}
}
uncli, err := cli.New(configPath, conn, opts.context) uncli, err := cli.New(configPath, conn, opts.context)
if err != nil { if err != nil {
return fmt.Errorf("initialise CLI: %w", err) return fmt.Errorf("initialise CLI: %w", err)
@@ -108,7 +83,7 @@ func main() {
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "", cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+ "Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
"Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock") "Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock")
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml", cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]") "Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml") _ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
@@ -121,11 +96,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,12 +119,11 @@ func main() {
NewDocsCommand(), NewDocsCommand(),
NewImagesCommand(), NewImagesCommand(),
NewPsCommand(), NewPsCommand(),
NewProxyCommand(),
caddy.NewRootCommand(), caddy.NewRootCommand(),
cmdcontext.NewRootCommand(), cmdcontext.NewRootCommand(),
dns.NewRootCommand(), dns.NewRootCommand(),
image.NewRootCommand(), image.NewRootCommand(),
cmdmachine.NewRootCommand(), machine.NewRootCommand(),
service.NewRootCommand(), service.NewRootCommand(),
service.NewExecCommand("service"), service.NewExecCommand("service"),
service.NewInspectCommand("service"), service.NewInspectCommand("service"),
@@ -156,39 +134,8 @@ 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(),
) )
if err := cmd.Execute(); err != nil { cobra.CheckErr(cmd.Execute())
if cancelled, ok := errors.AsType[*cli.CancelledError](err); ok {
fmt.Fprintln(os.Stderr, cancelled.Error())
os.Exit(1)
}
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())
})
} }
+75 -54
View File
@@ -6,14 +6,16 @@ import (
"sort" "sort"
"time" "time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/go-units" "github.com/docker/go-units"
"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/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/client"
) )
const ( const (
@@ -65,13 +67,12 @@ type containerInfo struct {
serviceName string serviceName string
machineName string machineName string
id string id string
name string
image string image string
status string status string
highlight containerHighlight highlight containerHighlight
created time.Time created time.Time
ip string ip string
// Hook type (e.g., "pre-deploy"), empty for regular containers.
hook string
} }
func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error { func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
@@ -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().
Title(" Collecting container info...").
Type(spinner.MiniDot).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
containers, err = collectContainers(ctx, clusterClient) containers, err = collectContainers(ctx, clusterClient)
return err return err
}) }).
Run()
if err != nil { if err != nil {
return fmt.Errorf("collect containers: %w", err) return fmt.Errorf("collect containers: %w", err)
} }
@@ -121,22 +132,24 @@ func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
} }
func printContainers(containers []containerInfo) error { func printContainers(containers []containerInfo) error {
t := tui.NewTable() t := table.New().
// Remove the default border.
Border(lipgloss.Border{}).
BorderTop(false).
BorderBottom(false).
BorderLeft(false).
BorderRight(false).
BorderHeader(false).
BorderColumn(false).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
}
// Regular style for data rows with padding.
return lipgloss.NewStyle().PaddingRight(3)
})
// Show HOOK column only when hook containers are present. t.Headers("SERVICE", "CONTAINER ID", "CONTAINER NAME", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
hasHooks := false
for _, ctr := range containers {
if ctr.hook != "" {
hasHooks = true
break
}
}
if hasHooks {
t.Headers("SERVICE", "CONTAINER ID", "IMAGE", "CREATED", "STATUS", "HOOK", "IP ADDRESS", "MACHINE")
} else {
t.Headers("SERVICE", "CONTAINER ID", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
}
for _, ctr := range containers { for _, ctr := range containers {
id := ctr.id id := ctr.id
@@ -158,36 +171,35 @@ func printContainers(containers []containerInfo) error {
statusStyle = lipgloss.NewStyle() // Default statusStyle = lipgloss.NewStyle() // Default
} }
if hasHooks {
t.Row( t.Row(
ctr.serviceName, ctr.serviceName,
id, id,
tui.FormatImage(ctr.image, tui.NoStyle), ctr.name,
created, ctr.image,
statusStyle.Render(ctr.status),
ctr.hook,
ctr.ip,
ctr.machineName,
)
} else {
t.Row(
ctr.serviceName,
id,
tui.FormatImage(ctr.image, tui.NoStyle),
created, created,
statusStyle.Render(ctr.status), statusStyle.Render(ctr.status),
ctr.ip, ctr.ip,
ctr.machineName, ctr.machineName,
) )
} }
}
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,21 +211,33 @@ 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")
}
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 msc.Metadata != nil && msc.Metadata.Error != "" {
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
msc.Metadata.Error))
continue continue
} }
machineName := msc.Metadata.MachineName for _, ctr := range msc.Containers {
if err := msc.Metadata.Err(); err != nil {
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s",
machineName, err))
continue
}
for _, ctr := range append(msc.Containers, msc.HookContainers...) {
if ctr.Container.State == nil || ctr.Container.Config == nil { if ctr.Container.State == nil || ctr.Container.Config == nil {
continue continue
} }
@@ -235,9 +259,6 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
highlight = highlightSuccess highlight = highlightSuccess
} else if ctr.Container.State.Status == "running" { } else if ctr.Container.State.Status == "running" {
highlight = highlightNormal highlight = highlightNormal
} else if ctr.IsHook() && ctr.Container.State.Status == "exited" && ctr.Container.State.ExitCode == 0 {
// Hook containers (e.g., pre-deploy) are expected to exit successfully.
highlight = highlightNormal
} else { // Other non-critical but noteworthy states } else { // Other non-critical but noteworthy states
highlight = highlightWarning highlight = highlightWarning
} }
@@ -255,12 +276,12 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
serviceName: ctr.ServiceName(), serviceName: ctr.ServiceName(),
machineName: machineName, machineName: machineName,
id: ctr.Container.ID, id: ctr.Container.ID,
name: ctr.Container.Name,
image: ctr.Container.Config.Image, image: ctr.Container.Config.Image,
status: status, status: status,
highlight: highlight, highlight: highlight,
created: created, created: created,
ip: ipStr, ip: ipStr,
hook: ctr.Config.Labels[api.LabelHook],
} }
containers = append(containers, info) containers = append(containers, info)
} }
+442
View File
@@ -0,0 +1,442 @@
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, "test-container", c.name)
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)
}
}
@@ -7,7 +7,6 @@ import (
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"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/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -49,20 +48,14 @@ If the service has multiple replicas and no container ID is specified, the comma
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(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)
serviceName, command := normalizeExecArgs(args) serviceName := args[0]
command := args[1:]
if len(command) == 0 { if len(command) == 0 {
command = DEFAULT_COMMAND command = DEFAULT_COMMAND
} }
return runExec(cmd.Context(), uncli, serviceName, command, opts) return runExec(cmd.Context(), uncli, serviceName, command, opts)
}, },
GroupID: groupID, GroupID: groupID,
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)
},
} }
execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background") execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background")
@@ -89,15 +82,6 @@ If the service has multiple replicas and no container ID is specified, the comma
return execCmd return execCmd
} }
func normalizeExecArgs(args []string) (serviceName string, command []string) {
serviceName = args[0]
command = args[1:]
if len(command) > 0 && command[0] == "--" {
command = command[1:]
}
return serviceName, command
}
func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error { func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error {
// Disable TTY allocation if not connected to a terminal // Disable TTY allocation if not connected to a terminal
if !tui.IsStdoutTerminal() { if !tui.IsStdoutTerminal() {
+116
View File
@@ -0,0 +1,116 @@
package service
import (
"context"
"fmt"
"os"
"slices"
"text/tabwriter"
"time"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type inspectOptions struct {
service string
}
func NewInspectCommand(groupID string) *cobra.Command {
opts := inspectOptions{}
cmd := &cobra.Command{
Use: "inspect SERVICE",
Short: "Display detailed information on a service.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0]
return inspect(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
return cmd
}
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer client.Close()
svc, err := client.InspectService(ctx, opts.service)
if err != nil {
return fmt.Errorf("inspect service: %w", err)
}
machines, err := client.ListMachines(ctx, nil)
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machinesNamesByID := make(map[string]string)
for _, m := range machines {
machinesNamesByID[m.Machine.Id] = m.Machine.Name
}
fmt.Printf("Service ID: %s\n", svc.ID)
fmt.Printf("Name: %s\n", svc.Name)
fmt.Printf("Mode: %s\n", svc.Mode)
fmt.Println()
// Parse created times for sorting and display.
createdTimes := make(map[string]time.Time, len(svc.Containers))
for _, ctr := range svc.Containers {
createdTimes[ctr.Container.ID], _ = time.Parse(time.RFC3339Nano, ctr.Container.Created)
}
// Sort containers by created time (newest first).
slices.SortFunc(svc.Containers, func(a, b api.MachineServiceContainer) int {
return createdTimes[b.Container.ID].Compare(createdTimes[a.Container.ID])
})
// Print the list of containers in a table format.
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tIP ADDRESS\tMACHINE"); err != nil {
return fmt.Errorf("write header: %w", err)
}
now := time.Now().UTC()
for _, ctr := range svc.Containers {
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
machine := machinesNamesByID[ctr.MachineID]
if machine == "" {
machine = ctr.MachineID
}
state, err := ctr.Container.HumanState()
if err != nil {
return fmt.Errorf("get human state: %w", err)
}
ip := ctr.Container.UncloudNetworkIP()
ipStr := ""
// The container might not have an IP if it's not running or uses the host network.
if ip.IsValid() {
ipStr = ip.String()
}
_, err = fmt.Fprintf(
tw,
"%s\t%s\t%s\t%s\t%s\t%s\n",
stringid.TruncateID(ctr.Container.ID),
ctr.Container.Config.Image,
created,
state,
ipStr,
machine,
)
if err != nil {
return fmt.Errorf("write row: %w", err)
}
}
return tw.Flush()
}
+363
View File
@@ -0,0 +1,363 @@
package service
import (
"context"
"errors"
"fmt"
"image/color"
"os"
"slices"
"strconv"
"strings"
"time"
"charm.land/lipgloss/v2"
mapset "github.com/deckarep/golang-set/v2"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose"
"github.com/spf13/cobra"
)
type logsOptions struct {
files []string
follow bool
tail string
since string
until string
utc bool
machines []string
}
func NewLogsCommand(groupID string) *cobra.Command {
var options logsOptions
cmd := &cobra.Command{
Use: "logs [SERVICE...]",
Aliases: []string{"log"},
Short: "View service logs.",
Long: `View logs from all replicas of the specified service(s) across all machines in the cluster.
If no services are specified, streams logs from all services defined in the Compose file
(compose.yaml by default or the file(s) specified with --file).`,
Example: ` # View recent logs for a service.
uc logs web
# Stream logs in real-time (follow mode).
uc logs -f web
# View logs from multiple services.
uc logs web api db
# View logs from all services in compose.yaml.
uc logs
# Show last 20 lines per replica (default is 100).
uc logs -n 20 web
# Show all logs without line limit.
uc logs -n all web
# View logs from a specific time range.
uc logs --since 3h --until 1h30m web
# View logs only from replicas running on specific machines.
uc logs -m machine1,machine2 web api`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return runLogs(cmd.Context(), uncli, args, options)
},
GroupID: groupID,
}
cmd.Flags().StringSliceVar(&options.files, "file", nil,
"One or more Compose files to load service names from when no services are specified. (default compose.yaml)")
cmd.Flags().BoolVarP(&options.follow, "follow", "f", false,
"Continually stream new logs.")
cmd.Flags().StringSliceVarP(&options.machines, "machine", "m", nil,
"Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.")
cmd.Flags().StringVar(&options.since, "since", "",
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
"Examples:\n"+
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
" --since 1h Relative duration (1 hour ago)\n"+
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
cmd.Flags().StringVarP(&options.tail, "tail", "n", "100",
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.")
cmd.Flags().StringVar(&options.until, "until", "",
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
"See --since for examples.")
cmd.Flags().BoolVar(&options.utc, "utc", false,
"Print timestamps in UTC instead of local timezone.")
return cmd
}
func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logsOptions) error {
// If no services specified, try to load them from the Compose file(s).
fromCompose := false
if len(serviceNames) == 0 {
fromCompose = true
project, err := compose.LoadProject(ctx, opts.files)
if err != nil {
return fmt.Errorf("load Compose file(s): %w", err)
}
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
// View logs for all services, including disabled by inactive profiles.
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...)
if len(serviceNames) == 0 {
return errors.New("no services found in Compose file(s)")
}
}
// Parse tail option.
tail := -1
if opts.tail != "all" {
tailInt, err := strconv.Atoi(opts.tail)
if err != nil {
return fmt.Errorf("invalid --tail value '%s': %w", opts.tail, err)
}
tail = tailInt
}
c, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer c.Close()
logsOpts := api.ServiceLogsOptions{
Follow: opts.follow,
Tail: tail,
Since: opts.since,
Until: opts.until,
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
}
// Collect log streams from all services. When service names come from a Compose file,
// skip the ones that are not found in the cluster (they may have been removed or not deployed yet).
machineIDsSet := mapset.NewSet[string]()
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames))
var foundServices, notFoundServices []string
for _, serviceName := range serviceNames {
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts)
if err != nil {
if errors.Is(err, api.ErrNotFound) && fromCompose {
notFoundServices = append(notFoundServices, serviceName)
continue
}
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err)
}
svcStreams = append(svcStreams, ch)
foundServices = append(foundServices, serviceName)
machineIDs := svc.MachineIDs()
machineIDsSet.Append(machineIDs...)
}
if fromCompose {
if len(foundServices) == 0 {
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
strings.Join(opts.files, ", "))
}
serviceNames = foundServices
for _, name := range notFoundServices {
tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
}
}
var stream <-chan api.ServiceLogEntry
if len(serviceNames) == 1 {
stream = svcStreams[0]
} else {
// Merge all service streams into a single sorted stream without stall detection as its handled per-service.
merger := client.NewLogMerger(svcStreams, client.LogMergerOptions{})
stream = merger.Stream()
}
// Fetch machine names for all machines (machineIDsSet) service containers are running on.
machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: machineIDsSet.ToSlice()})
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machineNames := make([]string, 0, len(machines))
for _, m := range machines {
machineNames = append(machineNames, m.Machine.Name)
}
formatter := newLogFormatter(machineNames, serviceNames, opts.utc)
// Print merged logs.
for entry := range stream {
if entry.Err != nil {
formatter.printError(entry)
continue
}
formatter.printEntry(entry)
}
return nil
}
// Available colors for machine/service differentiation.
var colorPalette = []color.Color{
lipgloss.BrightGreen,
lipgloss.BrightYellow,
lipgloss.BrightBlue,
lipgloss.BrightMagenta,
lipgloss.BrightCyan,
lipgloss.Green,
lipgloss.Yellow,
lipgloss.Blue,
lipgloss.Magenta,
lipgloss.Cyan,
}
// logFormatter handles formatting and printing of log entries with dynamic column alignment.
type logFormatter struct {
machineNames []string
serviceNames []string
maxMachineWidth int
maxServiceWidth int
utc bool
}
func newLogFormatter(machineNames, serviceNames []string, utc bool) *logFormatter {
slices.Sort(machineNames)
slices.Sort(serviceNames)
maxMachineWidth := 0
for _, name := range machineNames {
if len(name) > maxMachineWidth {
maxMachineWidth = len(name)
}
}
maxServiceWidth := 0
for _, name := range serviceNames {
if len(name) > maxServiceWidth {
maxServiceWidth = len(name)
}
}
return &logFormatter{
machineNames: machineNames,
serviceNames: serviceNames,
maxMachineWidth: maxMachineWidth,
maxServiceWidth: maxServiceWidth,
utc: utc,
}
}
// formatTimestamp formats timestamp using local timezone or UTC if configured.
func (f *logFormatter) formatTimestamp(t time.Time) string {
if f.utc {
t = t.UTC()
} else {
t = t.In(time.Local)
}
dimStyle := lipgloss.NewStyle().Faint(true)
return dimStyle.Render(t.Format(time.StampMilli))
}
func (f *logFormatter) formatMachine(name string) string {
style := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxMachineWidth - len(name))
if len(f.serviceNames) == 1 {
// Machine name is coloured for single-service logs.
i := slices.Index(f.machineNames, name)
if i == -1 {
f.machineNames = append(f.machineNames, name)
i = len(f.machineNames) - 1
}
style = style.Foreground(colorPalette[i%len(colorPalette)])
}
return style.Render(name)
}
func (f *logFormatter) formatServiceContainer(serviceName, containerID string) string {
styleService := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxServiceWidth - len(serviceName))
styleContainer := lipgloss.NewStyle().Faint(true)
if len(f.serviceNames) > 1 {
// Service name is coloured for multi-service logs.
i := slices.Index(f.serviceNames, serviceName)
if i == -1 {
f.serviceNames = append(f.serviceNames, serviceName)
i = len(f.serviceNames) - 1
}
styleService = styleService.Foreground(colorPalette[i%len(colorPalette)])
}
return styleService.Render(serviceName) + styleContainer.Render("["+containerID[:5]+"]")
}
// printEntry prints a single log entry with proper formatting.
func (f *logFormatter) printEntry(entry api.ServiceLogEntry) {
if entry.Stream != api.LogStreamStdout && entry.Stream != api.LogStreamStderr {
return
}
var output strings.Builder
// Timestamp
output.WriteString(f.formatTimestamp(entry.Timestamp))
output.WriteString(" ")
// Machine name
output.WriteString(f.formatMachine(entry.Metadata.MachineName))
output.WriteString(" ")
// Service[container_id]
output.WriteString(f.formatServiceContainer(entry.Metadata.ServiceName, entry.Metadata.ContainerID))
output.WriteString(" ")
// Message
output.Write(entry.Message)
// Print to appropriate stream.
if entry.Stream == api.LogStreamStderr {
fmt.Fprint(os.Stderr, output.String())
} else {
fmt.Print(output.String())
}
}
// printError prints an error entry (e.g., stalled stream warning).
func (f *logFormatter) printError(entry api.ServiceLogEntry) {
if entry.Metadata.ContainerID != "" {
msg := fmt.Sprintf("WARNING: log stream from %s[%s] on machine '%s'",
entry.Metadata.ServiceName,
stringid.TruncateID(entry.Metadata.ContainerID),
entry.Metadata.MachineName)
if errors.Is(entry.Err, api.ErrLogStreamStalled) {
msg += " stopped responding"
} else {
msg += fmt.Sprintf(": %v", entry.Err)
}
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow
fmt.Fprintln(os.Stderr, style.Render(msg))
} else {
msg := fmt.Sprintf("ERROR: %v", entry.Err)
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) // Bold bright red
fmt.Fprintln(os.Stderr, style.Render(msg))
}
}
@@ -3,12 +3,12 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"slices" "slices"
"strings" "strings"
"text/tabwriter"
"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/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -50,22 +50,20 @@ func list(ctx context.Context, uncli *cli.CLI) error {
}) })
// Print the list of services in a table format. // Print the list of services in a table format.
t := tui.NewTable() tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
// Include the ID column if there are duplicate service names to differentiate them. // Include the ID column if there are duplicate service names to differentiate them.
headers := []string{"NAME", "MODE", "REPLICAS", "IMAGE", "ENDPOINTS"}
if haveDuplicateNames { if haveDuplicateNames {
headers = append([]string{"ID"}, headers...) if _, err = fmt.Fprintf(tw, "ID\t"); err != nil {
return fmt.Errorf("write header: %w", err)
}
}
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tIMAGE\tENDPOINTS"); err != nil {
return fmt.Errorf("write header: %w", err)
} }
t.Headers(headers...)
for _, s := range services { for _, s := range services {
images := s.Images() images := strings.Join(s.Images(), ", ")
for i, img := range images { endpoints := strings.Join(s.Endpoints(), ", ")
images[i] = tui.FormatImage(img, tui.NoStyle)
}
formattedImages := strings.Join(images, tui.Faint.Render(", "))
endpoints := strings.Join(s.Endpoints(), tui.Faint.Render(", "))
// If no endpoints from ports, check if the service uses custom Caddy config. // If no endpoints from ports, check if the service uses custom Caddy config.
if endpoints == "" { if endpoints == "" {
@@ -76,13 +74,15 @@ func list(ctx context.Context, uncli *cli.CLI) error {
} }
} }
row := []string{s.Name, s.Mode, fmt.Sprintf("%d", len(s.Containers)), formattedImages, endpoints}
if haveDuplicateNames { if haveDuplicateNames {
row = append([]string{s.ID}, row...) if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
return fmt.Errorf("write row: %w", err)
} }
t.Row(row...)
} }
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n",
lipgloss.Println(t) s.Name, s.Mode, len(s.Containers), images, endpoints); err != nil {
return nil return fmt.Errorf("write row: %w", err)
}
}
return tw.Flush()
} }
@@ -6,7 +6,6 @@ import (
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -32,10 +31,6 @@ directives in image Dockerfiles) are automatically removed with their containers
return rm(cmd.Context(), uncli, opts) return rm(cmd.Context(), uncli, opts)
}, },
GroupID: groupID, GroupID: groupID,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return completion.Services(cmd.Context(), uncli, args, toComplete)
},
} }
return cmd return cmd
} }
@@ -20,7 +20,6 @@ func NewRootCommand() *cobra.Command {
NewScaleCommand(""), NewScaleCommand(""),
NewStartCommand(""), NewStartCommand(""),
NewStopCommand(""), NewStopCommand(""),
NewUpdateCommand(),
) )
return cmd return cmd
} }
@@ -11,7 +11,6 @@ import (
"github.com/docker/docker/daemon/names" "github.com/docker/docker/daemon/names"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy" "github.com/psviderski/uncloud/pkg/client/deploy"
@@ -21,16 +20,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 +63,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 +71,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 +82,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 +92,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,14 +119,11 @@ 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"+
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only") " -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
completion.MachinesFlag(cmd)
return cmd return cmd
} }
@@ -205,18 +186,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 +226,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 +295,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]).
@@ -11,7 +11,6 @@ import (
"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/internal/cli" "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/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -30,11 +29,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]
@@ -47,13 +44,6 @@ func NewScaleCommand(groupID string) *cobra.Command {
return scale(cmd.Context(), uncli, opts) return scale(cmd.Context(), uncli, opts)
}, },
GroupID: groupID, GroupID: groupID,
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().BoolVarP(&opts.yes, "yes", "y", false, cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
@@ -138,7 +128,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")
} }
@@ -157,7 +147,8 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
return fmt.Errorf("confirm scaling: %w", err) return fmt.Errorf("confirm scaling: %w", err)
} }
if !confirmed { if !confirmed {
return cli.Cancelled("Scaling cancelled. No changes were made.") fmt.Println("Cancelled. No changes were made.")
return nil
} }
} }
@@ -7,7 +7,6 @@ import (
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -31,10 +30,6 @@ Services can be specified by name or ID.`,
return start(cmd.Context(), uncli, opts) return start(cmd.Context(), uncli, opts)
}, },
GroupID: groupID, GroupID: groupID,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return completion.Services(cmd.Context(), uncli, args, toComplete)
},
} }
return cmd return cmd
} }
@@ -8,7 +8,6 @@ import (
"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/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -34,10 +33,6 @@ Services can be specified by name or ID. Stopped services can be restarted with
return stop(cmd.Context(), uncli, opts) return stop(cmd.Context(), uncli, opts)
}, },
GroupID: groupID, GroupID: groupID,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return completion.Services(cmd.Context(), uncli, args, toComplete)
},
} }
cmd.Flags().StringVarP(&opts.signal, "signal", "s", "", cmd.Flags().StringVarP(&opts.signal, "signal", "s", "",
"Signal to send to each container's main process.\n"+ "Signal to send to each container's main process.\n"+
@@ -8,9 +8,8 @@ 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/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -51,8 +50,6 @@ func NewCreateCommand() *cobra.Command {
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "", cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to create the volume on.") "Name or ID of the machine to create the volume on.")
completion.MachinesFlag(cmd)
return cmd return cmd
} }
@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/completion"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -27,21 +26,12 @@ func NewInspectCommand() *cobra.Command {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return inspect(cmd.Context(), uncli, args[0], opts) return inspect(cmd.Context(), uncli, args[0], 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.Volumes(cmd.Context(), uncli, args, toComplete)
},
} }
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "", cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine where the volume is located. "+ "Name or ID of the machine where the volume is located. "+
"If not specified, the volume will be searched across all machines.") "If not specified, the volume will be searched across all machines.")
completion.MachinesFlag(cmd)
return cmd return cmd
} }
@@ -3,13 +3,12 @@ package volume
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"slices" "slices"
"strings" "strings"
"text/tabwriter"
"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/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -38,8 +37,6 @@ func NewListCommand() *cobra.Command {
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false, cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
"Only display volume names.") "Only display volume names.")
completion.MachinesFlag(cmd)
return cmd return cmd
} }
@@ -89,13 +86,16 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
} }
// Print the volumes in a table format. // Print the volumes in a table format.
t := tui.NewTable() tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
t.Headers("NAME", "DRIVER", "MACHINE") fmt.Fprintln(tw, "NAME\tDRIVER\tMACHINE")
for _, v := range volumes { for _, v := range volumes {
t.Row(v.Volume.Name, v.Volume.Driver, v.MachineName) fmt.Fprintf(tw, "%s\t%s\t%s\n",
v.Volume.Name,
v.Volume.Driver,
v.MachineName,
)
} }
lipgloss.Println(t) return tw.Flush()
return nil
} }
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"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/tui" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -31,10 +30,6 @@ func NewRemoveCommand() *cobra.Command {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return remove(cmd.Context(), uncli, args, opts) return remove(cmd.Context(), uncli, args, opts)
}, },
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return completion.Volumes(cmd.Context(), uncli, args, toComplete)
},
} }
cmd.Flags().BoolVarP(&opts.force, "force", "f", false, cmd.Flags().BoolVarP(&opts.force, "force", "f", false,
@@ -46,8 +41,6 @@ func NewRemoveCommand() *cobra.Command {
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Do not prompt for confirmation before removing the volume(s).") "Do not prompt for confirmation before removing the volume(s).")
completion.MachinesFlag(cmd)
return cmd return cmd
} }
@@ -92,7 +85,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
} }
} }
+26 -33
View File
@@ -3,15 +3,13 @@ package wg
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"strings" "strings"
"text/tabwriter"
"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/tui"
"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"
@@ -46,9 +44,6 @@ func newShowCommand() *cobra.Command {
} }
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "", cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to show the configuration for. (default is connected machine)") "Name or ID of the machine to show the configuration for. (default is connected machine)")
completion.MachinesFlag(cmd)
return cmd return cmd
} }
@@ -61,7 +56,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)
@@ -77,18 +75,16 @@ func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
if err != nil { if err != nil {
return fmt.Errorf("list machines: %w", err) return fmt.Errorf("list machines: %w", err)
} }
machinesByPublicKey := make(map[string]*pb.MachineInfo) machinesNamesByPublicKey := make(map[string]string)
for _, m := range machines { for _, m := range machines {
publicKey := wgtypes.Key(m.Machine.Network.PublicKey).String() publicKey := wgtypes.Key(m.Machine.Network.PublicKey).String()
machinesByPublicKey[publicKey] = m.Machine machinesNamesByPublicKey[publicKey] = m.Machine.Name
} }
// Fetch the machine's info and RTTs for display. // Fetch the machine's name for more descriptive output
var selfMachine *pb.MachineDetails
inspectResp, err := client.MachineClient.InspectMachine(ctx, nil) inspectResp, err := client.MachineClient.InspectMachine(ctx, nil)
if err == nil { if err == nil {
selfMachine = inspectResp.Machines[0] fmt.Printf("Machine name: %s\n", inspectResp.Machines[0].Machine.Name)
fmt.Printf("Machine name: %s\n", selfMachine.Machine.Name)
} }
fmt.Printf("WireGuard interface: %s\n", resp.InterfaceName) fmt.Printf("WireGuard interface: %s\n", resp.InterfaceName)
@@ -101,20 +97,15 @@ func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
return nil return nil
} }
t := tui.NewTable() tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
t.Headers("PEER", "PUBLIC KEY", "ENDPOINT", "HANDSHAKE", "RTT", "RECEIVED", "SENT", "ALLOWED IPS") if _, err = fmt.Fprintln(tw, "PEER\tPUBLIC KEY\tENDPOINT\tHANDSHAKE\tRECEIVED\tSENT\tALLOWED IPS"); err != nil {
return fmt.Errorf("write header: %w", err)
}
for _, peer := range resp.Peers { for _, peer := range resp.Peers {
publicKeyStr := wgtypes.Key(peer.PublicKey).String() machineName, ok := machinesNamesByPublicKey[wgtypes.Key(peer.PublicKey).String()]
machineName := "(unknown)" if !ok {
rtt := "-" machineName = "(unknown)"
if m, ok := machinesByPublicKey[publicKeyStr]; ok {
machineName = m.Name
if selfMachine != nil {
if stats, ok := selfMachine.Rtts[m.Id]; ok {
rtt = tui.FormatRTT(stats.Median.AsDuration())
}
}
} }
lastHandshake := "" lastHandshake := ""
@@ -122,18 +113,20 @@ func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
lastHandshake = time.Since(peer.LastHandshakeTime.AsTime()).Round(time.Second).String() + " ago" lastHandshake = time.Since(peer.LastHandshakeTime.AsTime()).Round(time.Second).String() + " ago"
} }
t.Row( _, err = fmt.Fprintf(
tw,
"%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
machineName, machineName,
publicKeyStr, wgtypes.Key(peer.PublicKey).String(),
peer.Endpoint, peer.Endpoint,
lastHandshake, lastHandshake,
rtt,
units.HumanSize(float64(peer.ReceiveBytes)), units.HumanSize(float64(peer.ReceiveBytes)),
units.HumanSize(float64(peer.TransmitBytes)), units.HumanSize(float64(peer.TransmitBytes)),
strings.Join(peer.AllowedIps, tui.Faint.Render(", ")), strings.Join(peer.AllowedIps, ", "),
) )
if err != nil {
return fmt.Errorf("write row: %w", err)
} }
}
lipgloss.Println(t) return tw.Flush()
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

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