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
Pasha Sviderski 15037f382a refactor: more descriptive error for using --connect with 'uc machine init' 2026-04-01 16:14:03 +10:00
Pasha Sviderski c3e666056f docs: add warning about using a consistent context name for x-context 2026-03-26 17:23:50 +10:00
Pasha Sviderski 12c82bdb05 ci: update the lint workflow to run linter instead of formatter 2026-03-25 16:29:53 +10:00
Pasha Sviderski 7eaf42444d ci: update mise action to v4, ignore changes to mise.lock 2026-03-25 16:22:55 +10:00
Pasha Sviderski 117c2abd23 ci: use latest version of mise action for go-tests 2026-03-25 15:08:36 +10:00
Pasha Sviderski 9fb20c30b3 chore: extend Uncloud-in-Docker section in HACKING.md 2026-03-25 15:03:22 +10:00
Miek GiebenandGitHub ba73259887 fix: get the correct corrosion version in ucind image (#285)
On arm64 the x86 version of corrosion was downloaded. This downloads
the correct version. Also added comments to HACKING.md on how to clean
up a broken cluster as I have done it twice now and forgot how to do it
both times.

Fixes: #284

~~~ shell
% ./ucind cluster create miektest
Cluster "miektest" initialised with machine "machine-1"
Waiting for cluster to be ready... done.
Cluster 'miektest' added to Uncloud config as the current context.
Cluster 'miektest' created.
~~~

`uc machine ls` also works.

Signed-off-by: Miek Gieben <miek@miek.nl>
2026-03-25 13:24:40 +10:00
Pasha Sviderski a0dbe4a983 chore: mise ucind:cleanup task to remove ucind managed containers and networks 2026-03-25 13:17:32 +10:00
Pasha Sviderski 21c65b9dbe refactor: remove irrelevant property from pid unit test 2026-03-24 18:59:15 +10:00
Pasha Sviderski 86251a9354 lint 2026-03-24 18:56:56 +10:00
Pasha Sviderski 30bcb021d2 refactor: move StopGracePeriod from ServiceSpec to ContainerSpec 2026-03-24 18:41:01 +10:00
Pasha Sviderski ada9bec2d4 refactor: remove docker dependency for Pid container property 2026-03-24 18:29:45 +10:00
Pasha Sviderski 18ea1bc4a6 chore: remove obsolete Makefile targets 2026-03-24 18:27:29 +10:00
Pasha Sviderski be0f833e21 chore: add dev:install and dev:reset mise commands to manually test daemon changes on dev machines 2026-03-24 18:11:42 +10:00
98439b7743 feat: support PID namespace mode in Compose (#276)
This adds `pid: host` compose key support.

Modelled after #238.

See: #237

Signed-off-by: Miek Gieben <miek@miek.nl>
Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-03-24 16:18:53 +10:00
Miek GiebenandGitHub 2ab58d24d4 chore: modernize the codebase using go 1.26 (#278)
This runs:

```
go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest -fix ./...
```

over the codebase, as this is using go 1.26, it can also use the new
new() functionallity so AsPtr and boolPtr is are needed anymore.

Signed-off-by: Miek Gieben <miek@miek.nl>
2026-03-21 21:16:09 +10:00
Pasha Sviderski a5baf6e5c3 docs: regenerate CLI reference 2026-03-19 17:45:39 +10:00
Pasha Sviderski 6c75277e59 docs: add HACKING.md guide on setting up a local dev environment 2026-03-19 17:35:55 +10:00
Pasha Sviderski 76880d901a chore: move proto generation to mise tasks 2026-03-19 17:32:48 +10:00
Pasha Sviderski 3f95583dd4 fix: plan formatting for service scaling (update verb), fix image and replicas diff 2026-03-19 15:29:21 +10:00
Pasha Sviderski f5a77b82b2 feat: add auto-confirm option for 'uc scale' command, always require confirmation, format scaling plan 2026-03-19 14:41:04 +10:00
Pasha Sviderski b4b1232375 refactor: format deployment plan for caddy (uc caddy deploy) 2026-03-19 14:22:01 +10:00
Pasha Sviderski e4cb71b878 refactor: simplify and speed up plan output formatting by removing name resolver dependency 2026-03-19 13:14:09 +10:00
Pasha Sviderski 95f80a7fda docs: correct Docker volume drivers supported, add links 2026-03-19 11:30:00 +10:00
442f1d97a1 fix: uc deploy should not crash in empty git repository (#275)
Co-authored-by: Anton Ovchinnikov <anton@tonyo.info>
2026-03-19 10:16:03 +10:00
Pasha Sviderski d2703cbc6b fix: remove extra line between services in deploy plan 2026-03-18 21:41:54 +10:00
Pasha Sviderski 0f4c211002 refactor: format compose deployment plan with style, display target context 2026-03-18 21:26:22 +10:00
Pasha Sviderski 68661e57c3 refactor: tui package for CLI styles and prompts, restyle confirmation 2026-03-18 16:25:25 +10:00
Pasha Sviderski 1735ef9521 refactor: GetContextOverrideOrCurrent to check if config used 2026-03-17 20:21:54 +10:00
Pasha Sviderski 838839a7e5 refactor: update deployment plan structure to use typed volume and service operations 2026-03-16 17:54:01 +10:00
Pasha Sviderski 940732e91c fix: sshcli unit test and lint 2026-03-14 15:04:50 +10:00
Pasha Sviderski 9e4c759ffc fix: separate stdout and stderr in SSH CLI executor to prevent output contamination (closes #270) 2026-03-14 14:56:04 +10:00
Pasha Sviderski 1525d182b5 feat: add cluster context support in Compose with x-context extension 2026-03-13 18:37:36 +10:00
Pasha Sviderski 8ae38cfd57 feat: add shm_size support in Compose and --shm-size flag for 'uc run' (closes #267) 2026-03-12 22:09:28 +10:00
Pasha Sviderski 6cc3a05ed2 chore: bump mise to 2026.3.7 on github actions 2026-03-11 17:56:06 +10:00
Pasha Sviderski 29a682f1ce chore: generate mise.lock for all platforms 2026-03-11 17:53:04 +10:00
Pasha Sviderski b2fce73254 docs: regenerate CLI reference 2026-03-11 17:08:51 +10:00
Pasha Sviderski 7f92a5db27 feat: add --ulimit flag for 'uc run' command 2026-03-11 17:04:11 +10:00
Pasha Sviderski a5c9085105 docs: add ulimits to Compose compatibility doc 2026-03-11 16:58:15 +10:00
Pasha Sviderski d344392c1b docs: add note on Compose compatibility doc to submit feature requests 2026-03-11 16:30:56 +10:00
Pasha Sviderski 2992bad05d chore: bump Go to 1.26.1 and golangci-lint to 2.11.2 on github actions 2026-03-10 19:50:05 +10:00
Pasha Sviderski 255448a59c chore: migrate to hub/v2 and bubbletea/v2 2026-03-10 19:47:25 +10:00
Pasha Sviderski cf3234aef9 chore: bump Go version to 1.26.1 2026-03-10 19:18:09 +10:00
Pasha Sviderski 9e39f03f75 chore: lint 2026-03-10 18:57:51 +10:00
Pasha Sviderski 33fcb2fe74 chore: migrate to lipgloss/v2 2026-03-10 18:54:34 +10:00
89 changed files with 2011 additions and 872 deletions
+18 -11
View File
@@ -18,6 +18,8 @@ on:
- "test/**" - "test/**"
permissions: permissions:
contents: read contents: read
env:
MISE_VERSION: "2026.3.7"
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,10 +27,12 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Go - name: Install Mise
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with: with:
go-version: "1.25.1" version: ${{ env.MISE_VERSION }}
- name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock
- name: Install dependencies - name: Install dependencies
run: | run: |
@@ -44,7 +48,7 @@ jobs:
- name: Run tests - name: Run tests
run: | run: |
make ucind-image mise ucind:image
make test make test
timeout-minutes: 15 timeout-minutes: 15
@@ -61,15 +65,18 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Mise - name: Install Mise
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3 uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with: with:
version: "2025.9.6" version: ${{ env.MISE_VERSION }}
env:
GITHUB_TOKEN: ${{ github.token }} # TODO: figure out how to properly lock all platforms in the lockfile and remove this hack.
# `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
run: git checkout mise.lock
- name: Generate Protobuf files - name: Generate Protobuf files
run: | run: |
make proto mise run proto
# check if the generated files are up to date # Check if the generated files are up to date.
git diff --exit-code || git diff --exit-code ||
(echo "Generated protobuf files are not up to date. Please run 'make proto' and commit the changes." && exit 1) (echo "Generated protobuf files are not up to date. Please run 'mise run proto' and commit the changes." && exit 1)
+9 -13
View File
@@ -13,6 +13,8 @@ on:
- "go.*" - "go.*"
permissions: permissions:
contents: read contents: read
env:
MISE_VERSION: "2026.3.7"
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -20,20 +22,14 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Go - name: Install Mise
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with: with:
go-version: "1.25.1" version: ${{ env.MISE_VERSION }}
- name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock
- name: golangci-lint - name: Lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 run: make lint
with:
version: v2.4.0
- name: Format code
run: |
make format
git diff --exit-code ||
(echo "Code is not formatted. Please run 'make format' and commit the changes." && exit 1)
timeout-minutes: 10 timeout-minutes: 10
+2
View File
@@ -8,7 +8,9 @@
*.so *.so
*.dylib *.dylib
/uncloud /uncloud
/uncloud-*
/uncloudd /uncloudd
/uncloudd-*
# OS X # OS X
.DS_Store .DS_Store
-97
View File
@@ -1,97 +0,0 @@
[[tools."aqua:vektra/mockery"]]
version = "3.5.3"
backend = "aqua:vektra/mockery"
[tools."aqua:vektra/mockery".platforms.linux-x64]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
size = 5498859
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
[tools."aqua:vektra/mockery".platforms.macos-arm64]
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
size = 5238877
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz"
[[tools.go]]
version = "1.25.1"
backend = "core:go"
[tools.go.platforms.linux-x64]
checksum = "sha256:7716a0d940a0f6ae8e1f3b3f4f36299dc53e31b16840dbd171254312c41ca12e"
size = 59660846
url = "https://dl.google.com/go/go1.25.1.linux-amd64.tar.gz"
[tools.go.platforms.macos-arm64]
checksum = "sha256:68deebb214f39d542e518ebb0598a406ab1b5a22bba8ec9ade9f55fb4dd94a6c"
size = 57906702
url = "https://dl.google.com/go/go1.25.1.darwin-arm64.tar.gz"
[[tools.golangci-lint]]
version = "2.4.0"
backend = "aqua:golangci/golangci-lint"
[tools.golangci-lint.platforms.linux-x64]
checksum = "sha256:fae792524c04424c0ac369f5b8076f04b45cf29fc945a370e55d369a8dc11840"
size = 14126779
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-linux-amd64.tar.gz"
[tools.golangci-lint.platforms.macos-arm64]
checksum = "sha256:cd4dd53fa09b6646baff5fd22b8c64d91db02c21c7496df27992d75d34feec59"
size = 13542526
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-darwin-arm64.tar.gz"
[[tools.node]]
version = "22.20.0"
backend = "core:node"
[tools.node.platforms.linux-x64]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
size = 56645685
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
[tools.node.platforms.macos-arm64]
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
size = 49838299
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-darwin-arm64.tar.gz"
[[tools.protoc]]
version = "27.3"
backend = "aqua:protocolbuffers/protobuf/protoc"
[tools.protoc.platforms.linux-x64]
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
size = 3235873
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
[tools.protoc.platforms.macos-arm64]
checksum = "blake3:650c2494c003e9fddd6f4164a0a36e31da51ae7e1e5208da8cb560a4fe886fcd"
size = 2372763
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-aarch_64.zip"
[[tools.protoc-gen-go]]
version = "1.34.2"
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
[tools.protoc-gen-go.platforms.linux-x64]
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
size = 1848331
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
[tools.protoc-gen-go.platforms.macos-arm64]
checksum = "blake3:c237256a7f718ac4ccdc037ee30025a0b7c3f79300d7831ec59d76d782443fd5"
size = 1784028
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
[[tools.protoc-gen-go-grpc]]
version = "1.5.1"
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
[tools.protoc-gen-go-grpc.platforms.linux-x64]
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
size = 4859466
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
[tools.protoc-gen-go-grpc.platforms.macos-arm64]
checksum = "blake3:3c668207565d23ea2cf006d699207e3a804ebb929a534bd38a3b4ff4e941e00c"
size = 4565944
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz"
-14
View File
@@ -1,14 +0,0 @@
[settings]
experimental = true
[tools]
"aqua:vektra/mockery" = "3.5.3"
go = "1.25.1"
golangci-lint = "2.4.0"
node = "22.20.0"
protoc = "27.3"
protoc-gen-go = "1.34.2"
protoc-gen-go-grpc = "1.5.1"
[env]
_.file = ".env"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
#MISE description="Build and install uncloud binaries on dev machines, restart daemon if running"
#MISE depends=["build:uncloudd-amd64", "build:uncloudd-arm64"]
#MISE raw=true
#USAGE arg "<hosts>" var=#true var_min=1 help="Hosts in [user@]host[/arch] format, arch defaults to amd64"
for arg in ${usage_hosts}; do
if [[ "$arg" == */* ]]; then
host="${arg%/*}"
arch="${arg##*/}"
else
host="$arg"
arch="amd64"
fi
binary="uncloudd-linux-${arch}"
echo "==> Installing uncloudd on ${host} (${arch})..."
scp "$binary" "${host}:/tmp/"
ssh "$host" "sudo install /tmp/${binary} /usr/local/bin/uncloudd && rm /tmp/${binary} && sudo systemctl try-restart uncloud"
done
rm -f uncloudd-linux-amd64 uncloudd-linux-arm64
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
#MISE description="Stop uncloud daemon and wipe state on dev machines"
#USAGE arg "<hosts>" var=#true var_min=1 help="Hosts in [user@]host format"
for host in ${usage_hosts}; do
# Strip optional /arch suffix since reset doesn't need it.
host="${host%/*}"
echo "==> Resetting ${host}..."
ssh "$host" "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
done
echo "==> Done"
+5 -4
View File
@@ -1,6 +1,6 @@
ARG ALPINE_VERSION=3.20.3 ARG ALPINE_VERSION=3.23.3
FROM golang:1.25.1-alpine AS uncloudd FROM golang:1.26.1-alpine AS uncloudd
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@@ -39,10 +39,11 @@ CMD ["corrosion", "agent"]
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest" ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
ARG TARGETOS
ARG TARGETARCH
RUN apk --no-cache add crane RUN apk --no-cache add crane
RUN crane pull "${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:27.3.1-dind AS ucind FROM docker:27.3.1-dind AS ucind
+154
View File
@@ -0,0 +1,154 @@
# Developing Uncloud
A short guide on setting up your local development environment.
## Prerequisites
- [mise](https://mise.jdx.dev/) (development environment setup tool)
- [Docker](https://docs.docker.com/get-docker/) (for running end-to-end tests)
## Setup
Uncloud uses `mise` to install and manage development tools and dependencies. It makes it easy to create reproducible
development environments on developer machines and CI.
Follow the [Installing Mise](https://mise.jdx.dev/installing-mise.html) guide to get it set up on your machine.
Mark the project as trusted and install all required tools (Go, `protoc`, `golangci-lint`, etc.) by running from the
project root:
```shell
mise trust
mise install
```
This reads [`mise.toml`](mise.toml) and installs the exact versions of tools specified there. They will be available in
your `PATH` when you are in the project directory, so you can run `go` or `golangci-lint` directly. The tools won't be
available outside the project, so they don't interfere with system packages.
## Building
Build the CLI:
```shell
go build -o uc ./cmd/uncloud
```
Or build and run the CLI with a single command:
```shell
go run ./cmd/uncloud --help
```
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
Windows:
```shell
GOOS=linux GOARCH=amd64 go build -o uncloudd ./cmd/uncloudd
```
## Testing
Run all tests (unit and e2e):
```shell
make test
```
### End-to-end tests
E2e tests run inside Docker using the [Uncloud-in-Docker](#uncloud-in-docker-ucind) image. Build it first:
```shell
mise ucind:image
```
⚠️ NOTE: You need to rebuild the `ucind` image every time you make changes to the daemon code if you want to test them
in e2e tests.
Then run the tests:
```shell
make test-e2e
```
If tests leave leftover containers, clean them up with:
```shell
make test-clean
```
### Manual testing on dev machines
After making changes to the daemon code, you may want to test them in your dev cluster. Build and install `uncloudd` on
one or multiple dev machines over SSH with:
```shell
mise run dev:install user@host1 user@host2/arm64
```
It will also restart the `uncloud.service` daemon on those machines to pick up the new version. The host format is
`[user@]host[/arch]`. The architecture of a machine defaults to `amd64` if not specified.
To stop the daemon and wipe all Uncloud data on the dev machines, run:
```shell
mise run dev:reset user@host1 user@host2
```
## Linting and formatting
Lint and format the code:
```shell
make lint-and-fix
```
## Code generation
Update the generated Go code for the machine gRPC API after modifying `.proto` files:
```shell
mise run proto
```
Regenerate mocks:
```shell
make mocks
```
## Uncloud in Docker (UCinD)
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.
Cluster machines use the `ghcr.io/psviderski/ucind:latest` Docker image by default. It may not always have the
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
cluster:
```shell
mise ucind:image
```
Create a cluster with 3 machines:
```shell
mise ucind cluster create -m 3
```
This also sets the new cluster as the active context, so you can interact with it using the `uc` CLI right away.
Remove the cluster when you're done:
```shell
mise ucind cluster rm
```
Sometimes crashed tests or interrupted runs can leave orphaned containers and networks behind. Clean them all up with:
```shell
mise ucind:cleanup
```
+4 -51
View File
@@ -1,55 +1,12 @@
# TODO: Makefile is deprecated, add new targets as mise tasks in mise.toml instead.
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
update-dev:
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
ssh spy@192.168.40.243 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
ssh spy@192.168.40.176 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && \
rm uncloudd-linux-amd64
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
# ssh ubuntu@152.67.101.197 sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && \
# rm uncloudd-linux-arm64
update-restart-dev:
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
ssh spy@192.168.40.243 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
ssh spy@192.168.40.176 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
rm uncloudd-linux-amd64
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
# ssh ubuntu@152.67.101.197 "sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
# rm uncloudd-linux-arm64
reset-dev:
ssh spy@192.168.40.243 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
ssh spy@192.168.40.176 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
ssh ubuntu@152.67.101.197 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
demo-reset:
rm -fv ~/.config/uncloud/config.yaml
ssh ubuntu@152.67.101.197 "AUTO_CONFIRM=true sudo -E uncloud-uninstall && docker rmi caddy:2.9.1"
ssh root@5.223.45.199 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
ssh spy@192.168.40.243 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
.PHONY: ucind-cluster .PHONY: ucind-cluster
ucind-cluster: ucind-cluster:
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3) go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
.PHONY: proto
proto:
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
--proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto
.PHONY: proto-mise
proto-mise:
mise exec -- protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
--proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto
.PHONY: corrosion-image .PHONY: corrosion-image
corrosion-image: corrosion-image:
docker build -t "$(CORROSION_IMAGE)" --target corrosion . docker build -t "$(CORROSION_IMAGE)" --target corrosion .
@@ -58,10 +15,6 @@ corrosion-image:
corrosion-multiarch-image-push: corrosion-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion . docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
.PHONY: ucind-image
ucind-image:
docker build -t "$(UCIND_IMAGE)" --target ucind .
.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 .
@@ -84,12 +37,12 @@ test-e2e:
.PHONY: test-clean .PHONY: test-clean
test-clean: test-clean:
@CONTAINERS=$$(docker ps --filter "name=ucind-test" -q); \ @CONTAINERS=$$(docker ps --filter "label=ucind.managed" -q); \
if [ -n "$$CONTAINERS" ]; then \ if [ -n "$$CONTAINERS" ]; then \
echo "Killing containers..."; \ echo "Killing containers..."; \
docker kill $$CONTAINERS; \ docker kill $$CONTAINERS; \
fi; \ fi; \
CONTAINERS_STOPPED=$$(docker ps -a --filter "name=ucind-test" -q); \ CONTAINERS_STOPPED=$$(docker ps -a --filter "label=ucind.managed" -q); \
if [ -n "$$CONTAINERS_STOPPED" ]; then \ if [ -n "$$CONTAINERS_STOPPED" ]; then \
echo "Removing stopped containers..."; \ echo "Removing stopped containers..."; \
docker rm $$CONTAINERS_STOPPED; \ docker rm $$CONTAINERS_STOPPED; \
+2
View File
@@ -98,6 +98,8 @@ func runBuild(ctx context.Context, uncli *cli.CLI, opts buildOptions) error {
return fmt.Errorf("load compose file(s): %w", err) return fmt.Errorf("load compose file(s): %w", err)
} }
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps) servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps)
if err != nil { if err != nil {
return fmt.Errorf("determine services to build: %w", err) return fmt.Errorf("determine services to build: %w", err)
+47 -15
View File
@@ -9,9 +9,11 @@ import (
"slices" "slices"
"strings" "strings"
"charm.land/lipgloss/v2"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"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/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb" "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"
@@ -70,9 +72,11 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
if !errors.Is(err, api.ErrNotFound) { if !errors.Is(err, api.ErrNotFound) {
return fmt.Errorf("inspect caddy service: %w", err) return fmt.Errorf("inspect caddy service: %w", err)
} }
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName) fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(client.CaddyServiceName) +
tui.Faint.Render(" (not running)"))
} else { } else {
fmt.Printf("Service: %s (%s mode)\n", svc.Name, svc.Mode) 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. // Collect unique images of all containers in the running caddy service.
images := make(map[string]struct{}, len(svc.Containers)) images := make(map[string]struct{}, len(svc.Containers))
@@ -82,15 +86,19 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
currentImages := slices.Collect(maps.Keys(images)) currentImages := slices.Collect(maps.Keys(images))
if len(currentImages) > 1 { if len(currentImages) > 1 {
commaSeparatedImages := strings.Join(currentImages, ", ") formattedImages := make([]string, len(currentImages))
fmt.Printf("Current images (multiple versions detected): %s\n", commaSeparatedImages) 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 { } else {
fmt.Printf("Current image: %s\n", currentImages[0]) fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], lipgloss.NewStyle()))
} }
} }
if opts.image != "" { if opts.image != "" {
fmt.Printf("Target image: %s\n", opts.image) fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green))
} }
fmt.Println() fmt.Println()
@@ -105,7 +113,8 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
} }
if opts.image == "" { if opts.image == "" {
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.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) plan, err := d.Plan(ctx)
@@ -124,18 +133,41 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
} }
} }
// Initialise a machine and container name resolver to properly format the plan output. fmt.Println()
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc) fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
if err != nil { fmt.Println()
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
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() fmt.Println(plan.Format())
fmt.Println("Deployment plan:")
fmt.Println(plan.Format(resolver)) summary := plan.FormatSummary()
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
fmt.Println() fmt.Println()
confirmed, err := cli.Confirm() 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 { if err != nil {
return fmt.Errorf("confirm deployment: %w", err) return fmt.Errorf("confirm deployment: %w", err)
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package context
import ( import (
"fmt" "fmt"
"github.com/charmbracelet/huh" "charm.land/huh/v2"
"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/spf13/cobra" "github.com/spf13/cobra"
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"maps" "maps"
"slices" "slices"
"github.com/charmbracelet/huh" "charm.land/huh/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+39 -45
View File
@@ -4,17 +4,16 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings" "os"
"github.com/charmbracelet/lipgloss" "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/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/internal/cli/tui"
"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/deploy/operation"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -83,6 +82,8 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
return fmt.Errorf("load compose file(s): %w", err) return fmt.Errorf("load compose file(s): %w", err)
} }
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
if len(opts.services) > 0 { if len(opts.services) > 0 {
// Includes service dependencies by default. This is the default docker compose behavior. // Includes service dependencies by default. This is the default docker compose behavior.
project, err = project.WithSelectedServices(opts.services) project, err = project.WithSelectedServices(opts.services)
@@ -168,25 +169,47 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
return fmt.Errorf("plan deployment: %w", err) return fmt.Errorf("plan deployment: %w", err)
} }
if len(plan.Operations) == 0 { if plan.IsEmpty() {
fmt.Println("Services are up to date.") fmt.Println("Services are up to date.")
return nil return nil
} }
fmt.Println(lipgloss.NewStyle().Bold(true).Render("Deployment plan")) fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
if err = printPlan(ctx, clusterClient, plan); err != nil {
return fmt.Errorf("print deployment plan: %w", err)
}
fmt.Println() 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())
// 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 !cli.IsStdinTerminal() { 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")
} }
confirmed, err := cli.Confirm() 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 { if err != nil {
return fmt.Errorf("confirm deployment: %w", err) return fmt.Errorf("confirm deployment: %w", err)
} }
@@ -196,43 +219,14 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
} }
} }
title := "Deploying"
if deployTarget != "" {
title += " to " + tui.NameStyle.Render(deployTarget)
}
return 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(), "Deploying services") }, uncli.ProgressOut(), title)
}
func printPlan(ctx context.Context, cli *client.Client, plan operation.SequenceOperation) error {
for _, op := range plan.Operations {
svcPlan, ok := op.(*deploy.Plan)
if !ok {
fmt.Println("- " + op.Format(nil))
continue
}
svc, err := cli.InspectService(ctx, svcPlan.ServiceID)
if err != nil && !errors.Is(err, api.ErrNotFound) {
return fmt.Errorf("inspect service: %w", err)
}
// Initialise a machine and container name resolver to properly format the service plan output.
resolver, err := cli.ServiceOperationNameResolver(ctx, svc)
if err != nil {
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
}
fmt.Printf("- Deploy service [name=%s]\n", svcPlan.ServiceName)
fmt.Println(indent(svcPlan.Format(resolver), " "))
}
return nil
}
func indent(text, prefix string) string {
lines := strings.Split(text, "\n")
for i, line := range lines {
lines[i] = prefix + line
}
return strings.Join(lines, "\n")
} }
+6 -4
View File
@@ -3,17 +3,19 @@ package image
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"slices" "slices"
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss/table" "charm.land/lipgloss/v2/table"
"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/go-units" "github.com/docker/go-units"
"github.com/muesli/termenv"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -222,7 +224,7 @@ func formatPlatforms(platforms []string) string {
Foreground(lipgloss.Color("0")). Foreground(lipgloss.Color("0")).
Background(lipgloss.Color("152")) Background(lipgloss.Color("152"))
// Use fancy pill borders only if the output is a terminal with color support. // Use fancy pill borders only if the output is a terminal with color support.
if lipgloss.ColorProfile() != termenv.Ascii { if colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ASCII {
platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true) platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true)
} }
+18 -13
View File
@@ -8,12 +8,13 @@ import (
"strings" "strings"
"time" "time"
"github.com/charmbracelet/huh/spinner" "charm.land/huh/v2/spinner"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/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/machine/network" "github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
@@ -154,8 +155,12 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
err = spinner.New(). err = spinner.New().
Title(" Waiting for the machine to join the cluster..."). Title(" Waiting for the machine to join the cluster...").
Type(spinner.MiniDot). Type(spinner.MiniDot).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))). WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
TitleStyle(lipgloss.NewStyle()). return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error { ActionWithErr(func(ctx context.Context) error {
return machineClient.WaitClusterReady(ctx, 5*time.Minute) return machineClient.WaitClusterReady(ctx, 5*time.Minute)
}). }).
@@ -195,6 +200,8 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
} }
} }
fmt.Println()
fmt.Println("Preparing Caddy deployment...")
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{}) d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
if err != nil { if err != nil {
return fmt.Errorf("create caddy deployment: %w", err) return fmt.Errorf("create caddy deployment: %w", err)
@@ -205,22 +212,20 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
return fmt.Errorf("plan caddy deployment: %w", err) return fmt.Errorf("plan caddy deployment: %w", err)
} }
fmt.Println()
if len(plan.Operations) == 0 { if len(plan.Operations) == 0 {
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName) fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
} else { } else {
// Initialise a machine and container name resolver to properly format the plan output. fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, caddySvc) fmt.Println()
if err != nil { fmt.Print(plan.Format())
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
}
fmt.Println("caddy deployment plan:") summary := plan.FormatSummary()
fmt.Println(plan.Format(resolver)) fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
fmt.Println() fmt.Println()
if !opts.yes { if !opts.yes {
confirmed, err := cli.Confirm() confirmed, err := tui.Confirm("Proceed with deployment?")
if err != nil { if err != nil {
return fmt.Errorf("confirm deployment: %w", err) return fmt.Errorf("confirm deployment: %w", err)
} }
+12 -5
View File
@@ -7,8 +7,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/charmbracelet/huh/spinner" "charm.land/huh/v2/spinner"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy" "github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uncloud/dns" "github.com/psviderski/uncloud/cmd/uncloud/dns"
@@ -150,7 +150,10 @@ 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.
return fmt.Errorf("do not specify --connect when initialising a new cluster") return fmt.Errorf(
"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 the Uncloud config file (--uncloud-config)")
} }
netPrefix, err := netip.ParsePrefix(opts.network) netPrefix, err := netip.ParsePrefix(opts.network)
@@ -202,8 +205,12 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
err = spinner.New(). err = spinner.New().
Title(" Waiting for the cluster to be ready..."). Title(" Waiting for the cluster to be ready...").
Type(spinner.MiniDot). Type(spinner.MiniDot).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))). WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
TitleStyle(lipgloss.NewStyle()). return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error { ActionWithErr(func(ctx context.Context) error {
return client.WaitClusterReady(ctx, 1*time.Minute) return client.WaitClusterReady(ctx, 1*time.Minute)
}). }).
+5 -4
View File
@@ -9,11 +9,12 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss/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/internal/cli" "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/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -118,7 +119,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
} }
if !opts.yes { if !opts.yes {
confirmed, err := cli.Confirm() confirmed, err := tui.Confirm("")
if err != nil { if err != nil {
return fmt.Errorf("confirm removal: %w", err) return fmt.Errorf("confirm removal: %w", err)
} }
@@ -154,7 +155,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
// 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.GetContextOverrideOrCurrent() contextName := uncli.ContextOverrideOrCurrent()
if context, ok := uncli.Config.Contexts[contextName]; ok { if context, ok := uncli.Config.Contexts[contextName]; ok {
for i, c := range context.Connections { for i, c := range context.Connections {
if c.MachineID == m.Id { if c.MachineID == m.Id {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/netip" "net/netip"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/cmd/uncloud/caddy" "github.com/psviderski/uncloud/cmd/uncloud/caddy"
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context" cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
"github.com/psviderski/uncloud/cmd/uncloud/dns" "github.com/psviderski/uncloud/cmd/uncloud/dns"
+11 -5
View File
@@ -6,11 +6,12 @@ import (
"sort" "sort"
"time" "time"
"github.com/charmbracelet/huh/spinner" "charm.land/huh/v2/spinner"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss/table" "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/tui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -85,7 +86,12 @@ func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
err = spinner.New(). err = spinner.New().
Title(" Collecting container info..."). Title(" Collecting container info...").
Type(spinner.MiniDot). Type(spinner.MiniDot).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))). 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 { ActionWithErr(func(ctx context.Context) error {
containers, err = collectContainers(ctx, clusterClient) containers, err = collectContainers(ctx, clusterClient)
return err return err
@@ -226,7 +232,7 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
} }
if msc.Metadata != nil && msc.Metadata.Error != "" { if msc.Metadata != nil && msc.Metadata.Error != "" {
client.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName, tui.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
msc.Metadata.Error)) msc.Metadata.Error))
continue continue
} }
+36 -36
View File
@@ -39,25 +39,25 @@ func (m *mockClusterClient) ListMachines(ctx context.Context, in *emptypb.Empty,
func TestCollectContainers_NilMetadata(t *testing.T) { func TestCollectContainers_NilMetadata(t *testing.T) {
// Setup container data // Setup container data
containerData := map[string]interface{}{ containerData := map[string]any{
"Id": "container1", "Id": "container1",
"Name": "test-container", "Name": "test-container",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "test-image", "Image": "test-image",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON, err := json.Marshal(containerData) containerJSON, err := json.Marshal(containerData)
require.NoError(t, err) require.NoError(t, err)
serviceSpecJSON, err := json.Marshal(map[string]interface{}{}) serviceSpecJSON, err := json.Marshal(map[string]any{})
require.NoError(t, err) require.NoError(t, err)
// Setup mocks // Setup mocks
@@ -120,28 +120,28 @@ 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 // If we have multiple machines but receive nil metadata, it should return an error as it is ambiguous
// Setup container data // Setup container data
containerData1 := map[string]interface{}{ containerData1 := map[string]any{
"Id": "container1", "Id": "container1",
} }
containerJSON1, _ := json.Marshal(containerData1) containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]interface{}{ containerData2 := map[string]any{
"Id": "container2", "Id": "container2",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "test-image", "Image": "test-image",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON2, _ := json.Marshal(containerData2) containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{}) serviceSpecJSON, _ := json.Marshal(map[string]any{})
// Setup mocks // Setup mocks
mockDocker := &mockDockerClient{ mockDocker := &mockDockerClient{
@@ -210,41 +210,41 @@ func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
// Verify correct mapping of containers to machines when metadata is present // Verify correct mapping of containers to machines when metadata is present
// Setup container data // Setup container data
containerData1 := map[string]interface{}{ containerData1 := map[string]any{
"Id": "container1", "Id": "container1",
"Name": "container-1", "Name": "container-1",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "image-1", "Image": "image-1",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON1, _ := json.Marshal(containerData1) containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]interface{}{ containerData2 := map[string]any{
"Id": "container2", "Id": "container2",
"Name": "container-2", "Name": "container-2",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "image-2", "Image": "image-2",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON2, _ := json.Marshal(containerData2) containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{}) serviceSpecJSON, _ := json.Marshal(map[string]any{})
// Setup mocks // Setup mocks
mockDocker := &mockDockerClient{ mockDocker := &mockDockerClient{
@@ -321,22 +321,22 @@ func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) { func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
// Case: 1 msc with nil metadata but no machines at all // Case: 1 msc with nil metadata but no machines at all
containerData := map[string]interface{}{ containerData := map[string]any{
"Id": "container1", "Id": "container1",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "test-image", "Image": "test-image",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON, _ := json.Marshal(containerData) containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{}) serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{ mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{ listResp: &pb.ListServiceContainersResponse{
@@ -378,22 +378,22 @@ func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) { func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) {
// Case: msc with metadata that is not in the IP-to-name mapping // Case: msc with metadata that is not in the IP-to-name mapping
containerData := map[string]interface{}{ containerData := map[string]any{
"Id": "container1", "Id": "container1",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "test-image", "Image": "test-image",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON, _ := json.Marshal(containerData) containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{}) serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{ mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{ listResp: &pb.ListServiceContainersResponse{
+2 -1
View File
@@ -7,6 +7,7 @@ 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/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -83,7 +84,7 @@ If the service has multiple replicas and no container ID is specified, the comma
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 !cli.IsStdoutTerminal() { if !tui.IsStdoutTerminal() {
opts.noTty = true opts.noTty = true
} }
+18 -13
View File
@@ -4,16 +4,18 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"image/color"
"os" "os"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/docker/docker/pkg/stringid" "github.com/docker/docker/pkg/stringid"
"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/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose" "github.com/psviderski/uncloud/pkg/client/compose"
@@ -106,6 +108,9 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
if err != nil { if err != nil {
return fmt.Errorf("load Compose file(s): %w", err) return fmt.Errorf("load Compose file(s): %w", err)
} }
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
// View logs for all services, including disabled by inactive profiles. // View logs for all services, including disabled by inactive profiles.
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...) serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...)
if len(serviceNames) == 0 { if len(serviceNames) == 0 {
@@ -167,7 +172,7 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
serviceNames = foundServices serviceNames = foundServices
for _, name := range notFoundServices { for _, name := range notFoundServices {
client.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name)) tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
} }
} }
@@ -205,17 +210,17 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
} }
// Available colors for machine/service differentiation. // Available colors for machine/service differentiation.
var colorPalette = []lipgloss.Color{ var colorPalette = []color.Color{
lipgloss.Color("10"), // Bright green lipgloss.BrightGreen,
lipgloss.Color("11"), // Bright yellow lipgloss.BrightYellow,
lipgloss.Color("12"), // Bright blue lipgloss.BrightBlue,
lipgloss.Color("13"), // Bright magenta lipgloss.BrightMagenta,
lipgloss.Color("14"), // Bright cyan lipgloss.BrightCyan,
lipgloss.Color("2"), // Green lipgloss.Green,
lipgloss.Color("3"), // Yellow lipgloss.Yellow,
lipgloss.Color("4"), // Blue lipgloss.Blue,
lipgloss.Color("5"), // Magenta lipgloss.Magenta,
lipgloss.Color("6"), // Cyan lipgloss.Cyan,
} }
// logFormatter handles formatting and printing of log entries with dynamic column alignment. // logFormatter handles formatting and printing of log entries with dynamic column alignment.
+45 -7
View File
@@ -9,6 +9,7 @@ import (
dockeropts "github.com/docker/cli/opts" dockeropts "github.com/docker/cli/opts"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/daemon/names" "github.com/docker/docker/daemon/names"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
@@ -27,11 +28,13 @@ type runOptions struct {
machines []string machines []string
memory dockeropts.MemBytes memory dockeropts.MemBytes
mode string mode string
shmSize dockeropts.MemBytes
name string name string
privileged bool privileged bool
publish []string publish []string
pull string pull string
replicas uint replicas uint
ulimits []string
user string user string
volumes []string volumes []string
} }
@@ -76,8 +79,12 @@ func NewRunCommand(groupID string) *cobra.Command {
"Placement constraint by machine names, limiting which machines the service can run on. Can be specified "+ "Placement constraint by machine names, limiting which machines the service can run on. Can be specified "+
"multiple times or as a comma-separated list of machine names. (default is any suitable machine)") "multiple times or as a comma-separated list of machine names. (default is any suitable machine)")
cmd.Flags().VarP(&opts.memory, "memory", "", cmd.Flags().VarP(&opts.memory, "memory", "",
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix "+ "Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
"(b, k, m, g). 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)")
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"+
"with optional unit suffix (b, k, m, g). 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().StringVarP(&opts.name, "name", "n", "", cmd.Flags().StringVarP(&opts.name, "name", "n", "",
"Assign a name to the service. A random name is generated if not specified.") "Assign a name to the service. A random name is generated if not specified.")
@@ -102,6 +109,12 @@ func NewRunCommand(groupID string) *cobra.Command {
cmd.Flags().StringVarP(&opts.user, "user", "u", "", cmd.Flags().StringVarP(&opts.user, "user", "u", "",
"User name or UID and optionally group name or GID used for running the command inside service containers.\n"+ "User name or UID and optionally group name or GID used for running the command inside service containers.\n"+
"Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.") "Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.")
cmd.Flags().StringSliceVar(&opts.ulimits, "ulimit", nil,
"Set resource limits for service containers. Can be specified multiple times.\n"+
"Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.\n"+
"Examples:\n"+
" --ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files\n"+
" --ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes")
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil, cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
"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"+
@@ -194,13 +207,18 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
ports[i] = port ports[i] = port
} }
volumes, mounts, err := parseVolumeFlags(opts.volumes) placement := api.Placement{
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
}
ulimits, err := parseUlimits(opts.ulimits)
if err != nil { if err != nil {
return spec, err return spec, err
} }
placement := api.Placement{ volumes, mounts, err := parseVolumeFlags(opts.volumes)
Machines: cli.ExpandCommaSeparatedValues(opts.machines), if err != nil {
return spec, err
} }
spec = api.ServiceSpec{ spec = api.ServiceSpec{
@@ -213,6 +231,8 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
Resources: api.ContainerResources{ Resources: api.ContainerResources{
CPU: opts.cpu.Value(), CPU: opts.cpu.Value(),
Memory: opts.memory.Value(), Memory: opts.memory.Value(),
SharedMemory: opts.shmSize.Value(),
Ulimits: ulimits,
}, },
User: opts.user, User: opts.user,
VolumeMounts: mounts, VolumeMounts: mounts,
@@ -302,6 +322,24 @@ func parseVolumeFlags(volumes []string) ([]api.VolumeSpec, []api.VolumeMount, er
return specs, mounts, nil return specs, mounts, nil
} }
// parseUlimits parses ulimit flag values in Docker CLI format (type=soft[:hard]).
func parseUlimits(ulimits []string) (map[string]api.Ulimit, error) {
if len(ulimits) == 0 {
return nil, nil
}
result := make(map[string]api.Ulimit, len(ulimits))
for _, u := range ulimits {
parsed, err := units.ParseUlimit(u)
if err != nil {
return nil, fmt.Errorf("invalid ulimit '%s': %w", u, err)
}
result[parsed.Name] = api.Ulimit{Soft: parsed.Soft, Hard: parsed.Hard}
}
return result, nil
}
func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error) { func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error) {
var spec api.VolumeSpec var spec api.VolumeSpec
var mount api.VolumeMount var mount api.VolumeMount
@@ -320,8 +358,8 @@ func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error
volumeNoCopy := false volumeNoCopy := false
if len(parts) == 3 { if len(parts) == 3 {
opts := strings.Split(parts[2], ",") opts := strings.SplitSeq(parts[2], ",")
for _, opt := range opts { for opt := range opts {
switch opt { switch opt {
case "ro", "readonly": case "ro", "readonly":
mount.ReadOnly = true mount.ReadOnly = true
+57 -36
View File
@@ -2,12 +2,16 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os"
"strconv" "strconv"
"strings"
"github.com/charmbracelet/huh" "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/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -15,6 +19,7 @@ import (
type scaleOptions struct { type scaleOptions struct {
service string service string
replicas uint replicas uint
yes bool
} }
func NewScaleCommand(groupID string) *cobra.Command { func NewScaleCommand(groupID string) *cobra.Command {
@@ -22,9 +27,11 @@ func NewScaleCommand(groupID string) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "scale SERVICE REPLICAS", Use: "scale SERVICE REPLICAS",
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. Scaling down requires confirmation.", Long: "Scale a replicated service by changing the number of replicas.",
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
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]
@@ -39,6 +46,10 @@ func NewScaleCommand(groupID string) *cobra.Command {
GroupID: groupID, GroupID: groupID,
} }
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm scaling plan. Should be explicitly set when running non-interactively,\n"+
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
return cmd return cmd
} }
@@ -71,7 +82,8 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
currentReplicas := uint(len(svc.Containers)) currentReplicas := uint(len(svc.Containers))
if currentReplicas == opts.replicas { if currentReplicas == opts.replicas {
fmt.Printf("Service '%s' already has %d replicas. No changes required.\n", svc.Name, currentReplicas) fmt.Printf("Service %s already has %s replicas. No changes required.\n",
tui.NameStyle.Render(svc.Name), tui.Bold.Render(fmt.Sprintf("%d", currentReplicas)))
return nil return nil
} }
@@ -86,23 +98,51 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
} }
if len(plan.Operations) == 0 { if len(plan.Operations) == 0 {
fmt.Printf("Service '%s' is already scaled to %d replicas.\n", svc.Name, opts.replicas) fmt.Printf("Service %s is already scaled to %d replicas.\n", tui.NameStyle.Render(svc.Name), opts.replicas)
return nil return nil
} }
if opts.replicas < currentReplicas { fmt.Println(tui.Bold.Underline(true).Render("Scaling plan"))
// Initialise a machine and container name resolver to properly format the plan output.
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
if err != nil {
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
}
fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas)
fmt.Println(plan.Format(resolver))
fmt.Println() fmt.Println()
// Ask for confirmation before scaling down as it may cause data loss. directConn := uncli.DirectConnection()
confirmed, err := confirm() 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()
// Ask for confirmation unless auto-confirmed with --yes.
if !opts.yes {
if !tui.IsStdinTerminal() {
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")
}
title := "Proceed with scaling?"
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
// scaling on the wrong cluster.
if deployTarget != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with scaling on " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
}
confirmed, err := tui.Confirm(title)
if err != nil { if err != nil {
return fmt.Errorf("confirm scaling: %w", err) return fmt.Errorf("confirm scaling: %w", err)
} }
@@ -112,7 +152,8 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
} }
} }
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas) title := fmt.Sprintf("Scaling service %s (%d → %d replicas)",
tui.NameStyle.Render(svc.Name), currentReplicas, opts.replicas)
err = progress.RunWithTitle(ctx, func(ctx context.Context) error { err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if _, err = deployment.Run(ctx); err != nil { if _, err = deployment.Run(ctx); err != nil {
return fmt.Errorf("deploy service: %w", err) return fmt.Errorf("deploy service: %w", err)
@@ -125,23 +166,3 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
return nil return nil
} }
func confirm() (bool, error) {
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(
"Do you want to continue?",
).
Affirmative("Yes!").
Negative("No").
Value(&confirmed),
),
).WithAccessible(true)
if err := form.Run(); err != nil {
return false, err
}
return confirmed, nil
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"slices" "slices"
"strings" "strings"
"github.com/charmbracelet/huh" "charm.land/huh/v2"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"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"
) )
@@ -79,7 +80,7 @@ func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOpti
} }
fmt.Println() fmt.Println()
confirmed, err := cli.Confirm() confirmed, err := tui.Confirm("")
if err != nil { if err != nil {
return fmt.Errorf("confirm removal: %w", err) return fmt.Errorf("confirm removal: %w", err)
} }
+12 -12
View File
@@ -17,57 +17,57 @@ func newIPFSLogger(l *slog.Logger) *ipfsLogger {
return &ipfsLogger{log: *l} return &ipfsLogger{log: *l}
} }
func (l *ipfsLogger) Debug(args ...interface{}) { func (l *ipfsLogger) Debug(args ...any) {
l.log.Debug(fmt.Sprint(args...)) l.log.Debug(fmt.Sprint(args...))
} }
func (l *ipfsLogger) Debugf(format string, args ...interface{}) { func (l *ipfsLogger) Debugf(format string, args ...any) {
l.log.Debug(fmt.Sprintf(format, args...)) l.log.Debug(fmt.Sprintf(format, args...))
} }
func (l *ipfsLogger) Error(args ...interface{}) { func (l *ipfsLogger) Error(args ...any) {
l.log.Error(fmt.Sprint(args...)) l.log.Error(fmt.Sprint(args...))
} }
func (l *ipfsLogger) Errorf(format string, args ...interface{}) { func (l *ipfsLogger) Errorf(format string, args ...any) {
l.log.Error(fmt.Sprintf(format, args...)) l.log.Error(fmt.Sprintf(format, args...))
} }
func (l *ipfsLogger) Fatal(args ...interface{}) { func (l *ipfsLogger) Fatal(args ...any) {
l.log.Error(fmt.Sprint(args...)) l.log.Error(fmt.Sprint(args...))
os.Exit(1) os.Exit(1)
} }
func (l *ipfsLogger) Fatalf(format string, args ...interface{}) { func (l *ipfsLogger) Fatalf(format string, args ...any) {
l.log.Error(fmt.Sprintf(format, args...)) l.log.Error(fmt.Sprintf(format, args...))
os.Exit(1) os.Exit(1)
} }
func (l *ipfsLogger) Info(args ...interface{}) { func (l *ipfsLogger) Info(args ...any) {
l.log.Info(fmt.Sprint(args...)) l.log.Info(fmt.Sprint(args...))
} }
func (l *ipfsLogger) Infof(format string, args ...interface{}) { func (l *ipfsLogger) Infof(format string, args ...any) {
l.log.Info(fmt.Sprintf(format, args...)) l.log.Info(fmt.Sprintf(format, args...))
} }
func (l *ipfsLogger) Panic(args ...interface{}) { func (l *ipfsLogger) Panic(args ...any) {
msg := fmt.Sprint(args...) msg := fmt.Sprint(args...)
l.log.Error(msg) l.log.Error(msg)
panic(msg) panic(msg)
} }
func (l *ipfsLogger) Panicf(format string, args ...interface{}) { func (l *ipfsLogger) Panicf(format string, args ...any) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
l.log.Error(msg) l.log.Error(msg)
panic(msg) panic(msg)
} }
func (l *ipfsLogger) Warn(args ...interface{}) { func (l *ipfsLogger) Warn(args ...any) {
l.log.Warn(fmt.Sprint(args...)) l.log.Warn(fmt.Sprint(args...))
} }
func (l *ipfsLogger) Warnf(format string, args ...interface{}) { func (l *ipfsLogger) Warnf(format string, args ...any) {
l.log.Warn(fmt.Sprintf(format, args...)) l.log.Warn(fmt.Sprintf(format, args...))
} }
+19 -20
View File
@@ -1,19 +1,19 @@
module github.com/psviderski/uncloud module github.com/psviderski/uncloud
go 1.25 go 1.26
require ( require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/huh/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.1
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver v1.5.0
github.com/Masterminds/squirrel v1.5.4 github.com/Masterminds/squirrel v1.5.4
github.com/alecthomas/chroma/v2 v2.20.0 github.com/alecthomas/chroma/v2 v2.20.0
github.com/caddyserver/caddy/v2 v2.8.4 github.com/caddyserver/caddy/v2 v2.8.4
github.com/cenkalti/backoff/v4 v4.3.0 github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/colorprofile v0.4.2
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b
github.com/charmbracelet/lipgloss v1.1.0
github.com/compose-spec/compose-go/v2 v2.9.0 github.com/compose-spec/compose-go/v2 v2.9.0
github.com/containerd/errdefs v1.0.0 github.com/containerd/errdefs v1.0.0
github.com/containerd/platforms v1.0.0-rc.1 github.com/containerd/platforms v1.0.0-rc.1
@@ -43,7 +43,6 @@ require (
github.com/miekg/dns v1.1.65 github.com/miekg/dns v1.1.65
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/moby/term v0.5.2 github.com/moby/term v0.5.2
github.com/muesli/termenv v0.16.0
github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/psviderski/unregistry v0.4.1 github.com/psviderski/unregistry v0.4.1
@@ -57,8 +56,8 @@ require (
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0 golang.org/x/net v0.43.0
golang.org/x/sync v0.17.0 golang.org/x/sync v0.19.0
golang.org/x/sys v0.36.0 golang.org/x/sys v0.42.0
golang.org/x/term v0.34.0 golang.org/x/term v0.34.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
@@ -103,21 +102,24 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/aws/smithy-go v1.20.3 // indirect github.com/aws/smithy-go v1.20.3 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bgentry/speakeasy v0.2.0 // indirect github.com/bgentry/speakeasy v0.2.0 // indirect
github.com/buger/goterm v1.0.4 // indirect github.com/buger/goterm v1.0.4 // indirect
github.com/caddyserver/certmagic v0.21.4 // indirect github.com/caddyserver/certmagic v0.21.4 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/catppuccin/go v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/chzyer/readline v1.5.1 // indirect github.com/chzyer/readline v1.5.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/cgroups/v3 v3.0.5 // indirect github.com/containerd/cgroups/v3 v3.0.5 // indirect
github.com/containerd/console v1.0.5 // indirect github.com/containerd/console v1.0.5 // indirect
github.com/containerd/containerd/api v1.9.0 // indirect github.com/containerd/containerd/api v1.9.0 // indirect
@@ -150,7 +152,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsevents v0.2.0 // indirect github.com/fsnotify/fsevents v0.2.0 // indirect
@@ -239,13 +240,12 @@ require (
github.com/libp2p/go-libp2p v0.35.4 // indirect github.com/libp2p/go-libp2p v0.35.4 // indirect
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
github.com/libp2p/go-msgio v0.3.0 // indirect github.com/libp2p/go-msgio v0.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect
@@ -278,7 +278,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect github.com/morikuni/aec v1.0.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect
+46 -44
View File
@@ -1,3 +1,11 @@
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/huh/v2 v2.0.1 h1:9vhBjlIDuikdPKH+qnoG++GERVxqY0Lkv14xW57lj98=
charm.land/huh/v2 v2.0.1/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ= cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc= cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
@@ -119,10 +127,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudr
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -157,8 +163,8 @@ github.com/caddyserver/certmagic v0.21.4 h1:e7VobB8rffHv8ZZpSiZtEwnLDHUwLVYLWzWS
github.com/caddyserver/certmagic v0.21.4/go.mod h1:swUXjQ1T9ZtMv95qj7/InJvWLXURU85r+CfG0T+ZbDE= github.com/caddyserver/certmagic v0.21.4/go.mod h1:swUXjQ1T9ZtMv95qj7/InJvWLXURU85r+CfG0T+ZbDE=
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -168,28 +174,30 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b h1:oefmPctgff7OB4cDz3Ndp+ewwrh7i+fwth70qvZCieI= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8= github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
@@ -202,6 +210,10 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e85keuznYcH5rqI438v41pKcBl4ZxQ= github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e85keuznYcH5rqI438v41pKcBl4ZxQ=
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
@@ -341,8 +353,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
@@ -766,8 +776,8 @@ github.com/libp2p/go-yamux/v4 v4.0.1 h1:FfDR4S1wj6Bw2Pqbc8Uz7pCxeRBPbwsBbEdfwiCy
github.com/libp2p/go-yamux/v4 v4.0.1/go.mod h1:NWjl8ZTLOGlozrXSOZ/HlfG++39iKNnM5wwmtQP1YB4= github.com/libp2p/go-yamux/v4 v4.0.1/go.mod h1:NWjl8ZTLOGlozrXSOZ/HlfG++39iKNnM5wwmtQP1YB4=
github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw=
github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
@@ -795,10 +805,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@@ -895,12 +903,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
@@ -1059,7 +1063,6 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -1418,8 +1421,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1452,7 +1455,6 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1471,8 +1473,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
composetypes "github.com/compose-spec/compose-go/v2/types" composetypes "github.com/compose-spec/compose-go/v2/types"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
+25 -6
View File
@@ -71,12 +71,22 @@ func (cli *CLI) SetCurrentContext(name string) error {
return cli.Config.Save() return cli.Config.Save()
} }
func (cli *CLI) GetContextOverrideOrCurrent() string { func (cli *CLI) ContextOverrideOrCurrent() string {
contextName := cli.contextOverride if cli.contextOverride != "" {
if contextName == "" { return cli.contextOverride
contextName = cli.Config.CurrentContext
} }
return contextName if cli.Config != nil {
return cli.Config.CurrentContext
}
return ""
}
// DirectConnection returns the connection string if --connect or UNCLOUD_CONNECT was specified.
func (cli *CLI) DirectConnection() string {
if cli.conn != nil {
return cli.conn.String()
}
return ""
} }
// ConnectCluster connects to a cluster using the context override or the current context if not specified. // ConnectCluster connects to a cluster using the context override or the current context if not specified.
@@ -314,7 +324,7 @@ type AddMachineOptions struct {
// cluster. The machine client is connected to the new machine and can be used to interact with it. // cluster. The machine client is connected to the new machine and can be used to interact with it.
// Both client should be closed after use by the caller. // Both client should be closed after use by the caller.
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) { func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) {
contextName := cli.GetContextOverrideOrCurrent() contextName := cli.ContextOverrideOrCurrent()
c, err := cli.ConnectCluster(ctx) c, err := cli.ConnectCluster(ctx)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err) return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
@@ -561,6 +571,15 @@ func provisionOrConnectRemoteMachine(
return machineClient, nil return machineClient, nil
} }
// SetClusterContextIfUnset sets the cluster context override only if no --context flag was used
// and no --connect direct connection is active.
func (cli *CLI) SetClusterContextIfUnset(name string) {
if name == "" || cli.contextOverride != "" || cli.conn != nil {
return
}
cli.contextOverride = name
}
// ProgressOut returns an output stream for progress writer. // ProgressOut returns an output stream for progress writer.
func (cli *CLI) ProgressOut() *streams.Out { func (cli *CLI) ProgressOut() *streams.Out {
return streams.NewOut(os.Stdout) return streams.NewOut(os.Stdout)
+12 -11
View File
@@ -7,10 +7,11 @@ import (
"os" "os"
"time" "time"
"github.com/charmbracelet/bubbles/spinner" "charm.land/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"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/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/connector" "github.com/psviderski/uncloud/pkg/client/connector"
@@ -33,7 +34,7 @@ func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts Con
// If the stdout is not a terminal, it falls back to simple progress logs to stderr. // If the stdout is not a terminal, it falls back to simple progress logs to stderr.
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) { func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
// If stdout is not a terminal, fall back to simple progress logs. // If stdout is not a terminal, fall back to simple progress logs.
if !IsStdoutTerminal() { if !tui.IsStdoutTerminal() {
fmt.Fprintln(os.Stderr, "Connecting to", conn.String()) fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
cli, err := connectCluster(ctx, conn) cli, err := connectCluster(ctx, conn)
if err != nil { if err != nil {
@@ -125,7 +126,7 @@ type showSpinnerMsg struct{}
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel { func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
s := spinner.New() s := spinner.New()
s.Spinner = spinner.MiniDot s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // the same yellow as in compose progress s.Style = lipgloss.NewStyle().Foreground(lipgloss.Yellow) // the same yellow as in compose progress
return connectModel{ return connectModel{
ctx: ctx, ctx: ctx,
@@ -187,8 +188,8 @@ func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner, cmd = m.spinner.Update(msg) m.spinner, cmd = m.spinner.Update(msg)
return m, cmd return m, cmd
case tea.KeyMsg: case tea.KeyPressMsg:
if msg.Type == tea.KeyCtrlC { if msg.String() == "ctrl+c" {
m.result.err = fmt.Errorf("connection cancelled") m.result.err = fmt.Errorf("connection cancelled")
m.done = true m.done = true
return m, tea.Quit return m, tea.Quit
@@ -198,15 +199,15 @@ func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m connectModel) View() string { func (m connectModel) View() tea.View {
// Don't show anything if done or spinner not yet visible. // Don't show anything if done or spinner not yet visible.
if m.done || !m.showSpinner { if m.done || !m.showSpinner {
return "" return tea.NewView("")
} }
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153")) style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
return fmt.Sprintf("%s %s\n", return tea.NewView(fmt.Sprintf("%s %s\n",
m.spinner.View(), m.spinner.View(),
fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())), fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())),
) ))
} }
+1 -1
View File
@@ -20,7 +20,7 @@ func ExpandCommaSeparatedValues(values []string) []string {
var expanded []string var expanded []string
for _, value := range values { for _, value := range values {
for _, v := range strings.Split(value, ",") { for v := range strings.SplitSeq(value, ",") {
if v = strings.TrimSpace(v); v != "" { if v = strings.TrimSpace(v); v != "" {
expanded = append(expanded, v) expanded = append(expanded, v)
} }
+3 -2
View File
@@ -8,8 +8,9 @@ import (
"strings" "strings"
"time" "time"
"charm.land/huh/v2"
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
"github.com/charmbracelet/huh" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/sshexec" "github.com/psviderski/uncloud/internal/sshexec"
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
@@ -88,7 +89,7 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string
} }
func promptResetMachine() error { func promptResetMachine() error {
if !IsStdinTerminal() { if !tui.IsStdinTerminal() {
return errors.New("the remote machine is already initialised as a cluster member; " + return errors.New("the remote machine is already initialised as a cluster member; " +
"cannot ask to confirm reset in non-interactive mode, " + "cannot ask to confirm reset 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")
-38
View File
@@ -1,38 +0,0 @@
package cli
import (
"os"
"github.com/charmbracelet/huh"
"golang.org/x/term"
)
func Confirm() (bool, error) {
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(
"Do you want to continue?",
).
Affirmative("Yes!").
Negative("No").
Value(&confirmed),
),
).WithAccessible(true)
if err := form.Run(); err != nil {
return false, err
}
return confirmed, nil
}
// IsStdinTerminal checks if the standard input is a terminal (TTY).
func IsStdinTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
// IsStdoutTerminal checks if the standard output is a terminal (TTY).
func IsStdoutTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
+11
View File
@@ -0,0 +1,11 @@
package tui
import (
"fmt"
"os"
)
func PrintWarning(msg string) {
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
fmt.Fprintln(os.Stderr, styledMsg)
}
+53
View File
@@ -0,0 +1,53 @@
package tui
import (
"os"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"golang.org/x/term"
)
// Confirm shows a confirmation prompt with a yellow-styled title.
// If title is empty, it defaults to "Do you want to continue?".
func Confirm(title string) (bool, error) {
if title == "" {
title = "Do you want to continue?"
}
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(title).
Affirmative("Yes!").
Negative("No").
Value(&confirmed),
),
).WithTheme(ThemeConfirm()).
WithAccessible(true)
if err := form.Run(); err != nil {
return false, err
}
return confirmed, nil
}
// ThemeConfirm returns a huh theme with a bold yellow title style for the confirmation prompt.
func ThemeConfirm() huh.Theme {
return huh.ThemeFunc(func(isDark bool) *huh.Styles {
t := huh.ThemeBase(isDark)
t.Focused.Title = t.Focused.Title.Foreground(lipgloss.Yellow).Bold(true)
return t
})
}
// IsStdinTerminal checks if the standard input is a terminal (TTY).
func IsStdinTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
// IsStdoutTerminal checks if the standard output is a terminal (TTY).
func IsStdoutTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
+36
View File
@@ -0,0 +1,36 @@
package tui
import (
"charm.land/lipgloss/v2"
"github.com/distribution/reference"
)
var (
Faint = lipgloss.NewStyle().Faint(true)
Red = lipgloss.NewStyle().Foreground(lipgloss.Red)
Green = lipgloss.NewStyle().Foreground(lipgloss.Green)
Yellow = lipgloss.NewStyle().Foreground(lipgloss.Yellow)
Bold = lipgloss.NewStyle().Bold(true)
BoldRed = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Red)
BoldGreen = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Green)
BoldYellow = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Yellow)
NameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("152"))
)
// FormatImage renders an image reference with the given style, using a faint colon separator for tagged images.
// Returns the styled original string if parsing fails.
func FormatImage(image string, style lipgloss.Style) string {
ref, err := reference.ParseDockerRef(image)
if err != nil {
return style.Render(image)
}
if tagged, ok := ref.(reference.NamedTagged); ok {
return style.Render(reference.FamiliarName(ref)) +
Faint.Render(":") +
style.Render(tagged.Tag())
}
return style.Render(reference.FamiliarString(ref))
}
+1 -1
View File
@@ -26,7 +26,7 @@ type RecordResponse struct {
type AuthErrorResponse struct { type AuthErrorResponse struct {
Status int `json:"status,omitempty"` Status int `json:"status,omitempty"`
Message string `json:"msg,omitempty"` Message string `json:"msg,omitempty"`
Data authErrorData `json:"data,omitempty"` Data authErrorData `json:"data"`
} }
type authErrorData struct { type authErrorData struct {
+5 -3
View File
@@ -36,10 +36,12 @@ func InspectGitState(dir string) (GitState, error) {
return state, nil return state, nil
} }
// Get the current commit SHA. // Get the current commit SHA. An initialised but empty repo has no HEAD yet,
sha, err := gitCommand(dir, "rev-parse", "HEAD") // so treat it as a non-repo so callers can fall back to non-git logic.
sha, err := gitCommand(dir, "rev-parse", "--verify", "HEAD")
if err != nil { if err != nil {
return state, fmt.Errorf("get current commit SHA: %w", err) state.IsRepo = false
return state, nil
} }
state.SHA = strings.TrimSpace(sha) state.SHA = strings.TrimSpace(sha)
+16
View File
@@ -46,6 +46,22 @@ func TestInspectGitState_NotARepo(t *testing.T) {
assert.False(t, state.IsDirty) assert.False(t, state.IsDirty)
} }
func TestInspectGitState_EmptyRepo(t *testing.T) {
// Create a temporary git repo with no commits.
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
// An empty repo should be treated as a non-repo so that callers fall back to non-git logic.
assert.False(t, state.IsRepo)
assert.Empty(t, state.SHA)
assert.Empty(t, state.ShortSHA(7))
assert.True(t, state.Date.IsZero())
assert.False(t, state.IsDirty)
}
func TestInspectGitState_CleanRepo(t *testing.T) { func TestInspectGitState_CleanRepo(t *testing.T) {
// Create a temporary git repo. // Create a temporary git repo.
tmpDir := t.TempDir() tmpDir := t.TempDir()
+1 -1
View File
@@ -107,7 +107,7 @@ func (d *Director) remoteBackend(addr string) (*RemoteBackend, error) {
// FlushRemoteBackends closes all remote backend connections and removes them from the cache. // FlushRemoteBackends closes all remote backend connections and removes them from the cache.
func (d *Director) FlushRemoteBackends() { func (d *Director) FlushRemoteBackends() {
d.remoteBackends.Range(func(key, value interface{}) bool { d.remoteBackends.Range(func(key, value any) bool {
backend, ok := value.(*RemoteBackend) backend, ok := value.(*RemoteBackend)
if !ok { if !ok {
return true return true
+4 -3
View File
@@ -226,12 +226,13 @@ func (g *CaddyfileGenerator) Generate(
// Append error summary as comment if there were any invalid configs. // Append error summary as comment if there were any invalid configs.
if len(configErrors) > 0 { if len(configErrors) > 0 {
errorsComment := "# Skipped invalid user-defined configs:\n" var errorsComment strings.Builder
errorsComment.WriteString("# Skipped invalid user-defined configs:\n")
for _, e := range configErrors { for _, e := range configErrors {
errorsComment += fmt.Sprintf("# - %s\n", e) errorsComment.WriteString(fmt.Sprintf("# - %s\n", e))
} }
caddyfile += "\n" + errorsComment caddyfile += "\n" + errorsComment.String()
} }
return caddyfileHeader + "\n" + caddyfile, nil return caddyfileHeader + "\n" + caddyfile, nil
+2
View File
@@ -627,6 +627,7 @@ func (s *Server) CreateServiceContainer(
Binds: spec.Container.Volumes, Binds: spec.Container.Volumes,
Init: spec.Container.Init, Init: spec.Container.Init,
Mounts: mounts, Mounts: mounts,
PidMode: container.PidMode(spec.Container.PidMode),
PortBindings: portBindings, PortBindings: portBindings,
Privileged: spec.Container.Privileged, Privileged: spec.Container.Privileged,
Resources: container.Resources{ Resources: container.Resources{
@@ -642,6 +643,7 @@ func (s *Server) CreateServiceContainer(
RestartPolicy: container.RestartPolicy{ RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyUnlessStopped, Name: container.RestartPolicyUnlessStopped,
}, },
ShmSize: spec.Container.Resources.SharedMemory,
Sysctls: spec.Container.Sysctls, Sysctls: spec.Container.Sysctls,
} }
+1 -1
View File
@@ -103,7 +103,7 @@ func createIptablesChains() error {
} }
firstRejectRuleNum := 0 firstRejectRuleNum := 0
for _, line := range strings.Split(string(out), "\n") { for line := range strings.SplitSeq(string(out), "\n") {
fields := strings.Fields(line) fields := strings.Fields(line)
if len(fields) < 2 { if len(fields) < 2 {
continue continue
+1 -1
View File
@@ -116,7 +116,7 @@ func (p *Proxy) handleConnection(ctx context.Context, localConn net.Conn) {
}() }()
// Wait for both copies to complete or context cancel. // Wait for both copies to complete or context cancel.
for i := 0; i < 2; i++ { for range 2 {
select { select {
case <-ctx.Done(): case <-ctx.Done():
// Close connections to abort ongoing copies. // Close connections to abort ongoing copies.
+32 -23
View File
@@ -1,12 +1,15 @@
package sshexec package sshexec
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type SSHCLIRemote struct { type SSHCLIRemote struct {
@@ -25,15 +28,18 @@ func NewSSHCLIRemote(user, host string, port int, keyPath string) *SSHCLIRemote
} }
} }
// TODO: Refactor and reuse this with buildDialArgs and buildSSHArgs from // newSSHCommand creates an exec.Cmd for ssh that sends SIGINT on context cancellation, giving the
// SSHCLI Connector. // remote process a chance to exit gracefully before being killed.
func (r *SSHCLIRemote) buildSSHArgs() []string { func (r *SSHCLIRemote) newSSHCommand(ctx context.Context, cmd string) *exec.Cmd {
args := []string{"-o", "ConnectTimeout=5"} args := []string{
"-o", "ConnectTimeout=5",
// Disable pseudo-terminal allocation to prevent SSH from executing as a login shell.
"-T",
}
if r.port != 0 { if r.port != 0 {
args = append(args, "-p", strconv.Itoa(r.port)) args = append(args, "-p", strconv.Itoa(r.port))
} }
if r.keyPath != "" { if r.keyPath != "" {
args = append(args, "-i", r.keyPath) args = append(args, "-i", r.keyPath)
} }
@@ -42,36 +48,39 @@ func (r *SSHCLIRemote) buildSSHArgs() []string {
if r.user != "" { if r.user != "" {
dst = fmt.Sprintf("%s@%s", r.user, dst) dst = fmt.Sprintf("%s@%s", r.user, dst)
} }
args = append(args, dst) args = append(args, dst, cmd)
return args execCmd := exec.CommandContext(ctx, "ssh", args...)
execCmd.Cancel = func() error {
return execCmd.Process.Signal(os.Interrupt)
}
execCmd.WaitDelay = 5 * time.Second
return execCmd
} }
func (r *SSHCLIRemote) Run(ctx context.Context, cmd string) (string, error) { func (r *SSHCLIRemote) Run(ctx context.Context, cmd string) (string, error) {
args := r.buildSSHArgs() var stdout, stderr bytes.Buffer
args = append(args, cmd) err := r.Stream(ctx, cmd, &stdout, &stderr)
out := strings.TrimSpace(stdout.String())
execCmd := exec.CommandContext(ctx, "ssh", args...)
output, err := execCmd.CombinedOutput()
if err != nil { if err != nil {
return strings.TrimSpace(string(output)), return out, fmt.Errorf("%w: %s", err, stderr.String())
fmt.Errorf("run command on remote host: %w: %s", err, string(output))
} }
return strings.TrimSpace(string(output)), nil return out, nil
} }
func (r *SSHCLIRemote) Stream(ctx context.Context, cmd string, stdout, stderr io.Writer) error { func (r *SSHCLIRemote) Stream(ctx context.Context, cmd string, stdout, stderr io.Writer) error {
args := r.buildSSHArgs() sshCmd := r.newSSHCommand(ctx, cmd)
args = append(args, cmd) sshCmd.Stdout = stdout
sshCmd.Stderr = stderr
execCmd := exec.CommandContext(ctx, "ssh", args...) if err := sshCmd.Run(); err != nil {
execCmd.Stdout = stdout return fmt.Errorf("run command on remote host: %w", err)
execCmd.Stderr = stderr }
return nil
return execCmd.Run()
} }
// no-op as there is no persistent connection. // Close is no-op as there is no persistent connection.
func (r *SSHCLIRemote) Close() error { func (r *SSHCLIRemote) Close() error {
return nil return nil
} }
+39 -11
View File
@@ -1,12 +1,13 @@
package sshexec package sshexec
import ( import (
"context"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestSSHCLIRemote_buildSSHArgs(t *testing.T) { func TestSSHCLIRemote_newSSHCommand(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
@@ -15,51 +16,73 @@ func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
host string host string
port int port int
keyPath string keyPath string
cmd string
expected []string expected []string
}{ }{
{ {
name: "host only", name: "host only",
host: "example.com", host: "example.com",
expected: []string{"-o", "ConnectTimeout=5", "example.com"}, cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "example.com", "whoami",
},
}, },
{ {
name: "with user", name: "with user",
user: "root", user: "root",
host: "example.com", host: "example.com",
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"}, cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "root@example.com", "whoami",
},
}, },
{ {
name: "with port", name: "with port",
host: "example.com", host: "example.com",
port: 2222, port: 2222,
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "example.com"}, cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "example.com", "whoami",
},
}, },
{ {
name: "with key", name: "with key",
host: "example.com", host: "example.com",
keyPath: "/path/to/key", keyPath: "/path/to/key",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "example.com"}, cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-i", "/path/to/key", "example.com", "whoami",
},
}, },
{ {
name: "user and port", name: "user and port",
user: "ubuntu", user: "ubuntu",
host: "192.168.1.10", host: "192.168.1.10",
port: 2222, port: 2222,
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "ubuntu@192.168.1.10"}, cmd: "ls -la",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "ubuntu@192.168.1.10", "ls -la",
},
}, },
{ {
name: "user and key", name: "user and key",
user: "root", user: "root",
host: "example.com", host: "example.com",
keyPath: "/path/to/key", keyPath: "/path/to/key",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com"}, cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-i", "/path/to/key", "root@example.com", "whoami",
},
}, },
{ {
name: "port and key", name: "port and key",
host: "example.com", host: "example.com",
port: 22, port: 22,
keyPath: "~/.ssh/id_rsa", keyPath: "~/.ssh/id_rsa",
expected: []string{"-o", "ConnectTimeout=5", "-p", "22", "-i", "~/.ssh/id_rsa", "example.com"}, cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-p", "22", "-i", "~/.ssh/id_rsa", "example.com", "whoami",
},
}, },
{ {
name: "all options", name: "all options",
@@ -67,7 +90,11 @@ func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
host: "server.local", host: "server.local",
port: 2222, port: 2222,
keyPath: "~/.ssh/id_rsa", keyPath: "~/.ssh/id_rsa",
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "~/.ssh/id_rsa", "admin@server.local"}, cmd: "sudo bash -c 'echo hello'",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "-i", "~/.ssh/id_rsa",
"admin@server.local", "sudo bash -c 'echo hello'",
},
}, },
} }
@@ -81,8 +108,9 @@ func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
port: tt.port, port: tt.port,
keyPath: tt.keyPath, keyPath: tt.keyPath,
} }
result := remote.buildSSHArgs() execCmd := remote.newSSHCommand(context.Background(), tt.cmd)
assert.Equal(t, tt.expected, result) assert.Equal(t, tt.expected, execCmd.Args)
assert.NotNil(t, execCmd.Cancel, "Cancel should be set for graceful shutdown")
}) })
} }
} }
+67
View File
@@ -0,0 +1,67 @@
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
[[tools."aqua:vektra/mockery"]]
version = "3.5.3"
backend = "aqua:vektra/mockery"
[tools."aqua:vektra/mockery"."platforms.linux-x64"]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
[tools."aqua:vektra/mockery"."platforms.macos-arm64"]
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz"
[[tools.go]]
version = "1.26.1"
backend = "core:go"
[tools.go."platforms.linux-x64"]
checksum = "sha256:031f088e5d955bab8657ede27ad4e3bc5b7c1ba281f05f245bcc304f327c987a"
url = "https://dl.google.com/go/go1.26.1.linux-amd64.tar.gz"
[tools.go."platforms.macos-arm64"]
checksum = "sha256:353df43a7811ce284c8938b5f3c7df40b7bfb6f56cb165b150bc40b5e2dd541f"
url = "https://dl.google.com/go/go1.26.1.darwin-arm64.tar.gz"
[[tools.golangci-lint]]
version = "2.11.2"
backend = "aqua:golangci/golangci-lint"
[tools.golangci-lint."platforms.linux-x64"]
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
[tools.golangci-lint."platforms.macos-arm64"]
checksum = "sha256:11e9549b955bae4bfb71aa3b5c2762e446e2d3f975fe963d6d767b8f745d9344"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-arm64.tar.gz"
[[tools.node]]
version = "22.20.0"
backend = "core:node"
[tools.node."platforms.linux-x64"]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
[tools.node."platforms.macos-arm64"]
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-darwin-arm64.tar.gz"
[[tools.protoc]]
version = "27.3"
backend = "aqua:protocolbuffers/protobuf/protoc"
[[tools.protoc-gen-go]]
version = "1.34.2"
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
[tools.protoc-gen-go."platforms.linux-x64"]
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
[tools.protoc-gen-go."platforms.macos-arm64"]
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
[[tools.protoc-gen-go-grpc]]
version = "1.5.1"
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
+67
View File
@@ -0,0 +1,67 @@
[settings]
experimental = true
[tools]
"aqua:vektra/mockery" = "3.5.3"
go = "1.26.1"
golangci-lint = "2.11.2"
node = "22.20.0"
protoc = "27.3"
protoc-gen-go = "1.34.2"
protoc-gen-go-grpc = "1.5.1"
[vars]
ucind_image = "ghcr.io/psviderski/ucind:latest"
[env]
_.file = ".env"
[tasks."build:uncloudd-amd64"]
description = "Build uncloudd for linux/amd64"
run = """
VERSION=$(git describe --tags --always --dirty) \
GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/psviderski/uncloud/internal/version.version=$VERSION" \
-o uncloudd-linux-amd64 ./cmd/uncloudd
"""
outputs = ["uncloudd-linux-amd64"]
[tasks."build:uncloudd-arm64"]
description = "Build uncloudd for linux/arm64"
run = """
VERSION=$(git describe --tags --always --dirty) \
GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/psviderski/uncloud/internal/version.version=$VERSION" \
-o uncloudd-linux-arm64 ./cmd/uncloudd
"""
outputs = ["uncloudd-linux-arm64"]
[tasks."mise:lock"]
description = "Update mise.lock with hashes for all platforms"
run = "mise lock --platform linux-x64,macos-arm64"
[tasks.proto]
description = "Regenerate gRPC API code from .proto files"
run = """
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
--proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto
"""
[tasks.uc]
description = "Run the Uncloud CLI"
run = "go run ./cmd/uncloud"
[tasks.ucind]
description = "Run ucind CLI for managing local Docker-based clusters"
run = "go run ./cmd/ucind"
[tasks."ucind:image"]
description = "Build the ucind Docker image"
run = "docker build -t {{vars.ucind_image}} --target ucind ."
[tasks."ucind:cleanup"]
description = "Remove ucind managed containers and networks"
run = """
echo "==> Removing ucind managed containers..."
docker ps --filter "label=ucind.managed" -q | xargs docker rm -f
echo "==> Removing ucind managed networks..."
docker network ls --filter "label=ucind.managed" -q | xargs docker network rm -f
"""
+3 -1
View File
@@ -62,6 +62,8 @@ type VolumeClient interface {
} }
// AsPtr returns a pointer to the given value. Useful for optional fields in API structs. // AsPtr returns a pointer to the given value. Useful for optional fields in API structs.
//
//go:fix inline
func AsPtr[T any](v T) *T { func AsPtr[T any](v T) *T {
return &v return new(v)
} }
+4 -9
View File
@@ -8,11 +8,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// uint64Ptr is a convenience function to create a pointer to a uint64 value
func uint64Ptr(v uint64) *uint64 {
return &v
}
func TestConfigMount_GetNumericUid(t *testing.T) { func TestConfigMount_GetNumericUid(t *testing.T) {
t.Parallel() t.Parallel()
@@ -30,12 +25,12 @@ func TestConfigMount_GetNumericUid(t *testing.T) {
{ {
name: "valid numeric uid", name: "valid numeric uid",
uid: "1000", uid: "1000",
expected: uint64Ptr(1000), expected: new(uint64(1000)),
}, },
{ {
name: "zero uid", name: "zero uid",
uid: "0", uid: "0",
expected: uint64Ptr(0), expected: new(uint64(0)),
}, },
{ {
name: "invalid non-numeric uid", name: "invalid non-numeric uid",
@@ -96,12 +91,12 @@ func TestConfigMount_GetNumericGid(t *testing.T) {
{ {
name: "valid numeric gid", name: "valid numeric gid",
gid: "1000", gid: "1000",
expected: uint64Ptr(1000), expected: new(uint64(1000)),
}, },
{ {
name: "zero gid", name: "zero gid",
gid: "0", gid: "0",
expected: uint64Ptr(0), expected: new(uint64(0)),
}, },
{ {
name: "invalid non-numeric gid", name: "invalid non-numeric gid",
+2
View File
@@ -21,6 +21,8 @@ type ContainerResources struct {
Devices []DeviceMapping Devices []DeviceMapping
// DeviceReservations requests for access to things like GPUs. // DeviceReservations requests for access to things like GPUs.
DeviceReservations []container.DeviceRequest DeviceReservations []container.DeviceRequest
// SharedMemory is the size of the shared memory (in bytes) mounted at /dev/shm in the container.
SharedMemory int64
// Ulimits defines the resource limits for the container. // Ulimits defines the resource limits for the container.
Ulimits map[string]Ulimit Ulimits map[string]Ulimit
} }
+9 -11
View File
@@ -69,11 +69,8 @@ type ServiceSpec struct {
Ports []PortSpec Ports []PortSpec
// Replicas is the number of containers to run for the service. Only valid for a replicated service. // Replicas is the number of containers to run for the service. Only valid for a replicated service.
Replicas uint `json:",omitempty"` Replicas uint `json:",omitempty"`
// StopGracePeriod is how long to wait after SIGTERM before sending SIGKILL when stopping a container.
// Default is 10 seconds if not specified.
StopGracePeriod *time.Duration `json:",omitempty"`
// UpdateConfig configures how the service is updated during a deployment. // UpdateConfig configures how the service is updated during a deployment.
UpdateConfig UpdateConfig `json:",omitempty"` UpdateConfig UpdateConfig
// Volumes is list of data volumes that can be mounted into the container. // Volumes is list of data volumes that can be mounted into the container.
Volumes []VolumeSpec Volumes []VolumeSpec
} }
@@ -256,6 +253,8 @@ type ContainerSpec struct {
Init *bool Init *bool
// LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default. // LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default.
LogDriver *LogDriver LogDriver *LogDriver
// PidMode sets the PID namespace mode for the container. Currently only "" or "host" is supported.
PidMode string
// Privileged gives extended privileges to the container. This is a security risk and should be used with caution. // Privileged gives extended privileges to the container. This is a security risk and should be used with caution.
Privileged bool Privileged bool
// PullPolicy determines when to pull the image from the registry or use the image already available in the cluster. // PullPolicy determines when to pull the image from the registry or use the image already available in the cluster.
@@ -263,7 +262,10 @@ type ContainerSpec struct {
PullPolicy string PullPolicy string
// Resource allocation for the container. // Resource allocation for the container.
Resources ContainerResources Resources ContainerResources
// Namespaced kernel parameters to be set in container // StopGracePeriod is how long to wait after SIGTERM before sending SIGKILL when stopping the container.
// Default is 10 seconds if not specified.
StopGracePeriod *time.Duration `json:",omitempty"`
// Namespaced kernel parameters to be set in the container.
Sysctls map[string]string Sysctls map[string]string
// User overrides the default user of the image used to run the container. Format: user|UID[:group|GID]. // User overrides the default user of the image used to run the container. Format: user|UID[:group|GID].
User string User string
@@ -348,9 +350,7 @@ func (s *ContainerSpec) Clone() ContainerSpec {
} }
if s.Env != nil { if s.Env != nil {
spec.Env = make(EnvVars, len(s.Env)) spec.Env = make(EnvVars, len(s.Env))
for k, v := range s.Env { maps.Copy(spec.Env, s.Env)
spec.Env[k] = v
}
} }
if s.Healthcheck != nil { if s.Healthcheck != nil {
hc := *s.Healthcheck hc := *s.Healthcheck
@@ -380,9 +380,7 @@ func (s *ContainerSpec) Clone() ContainerSpec {
} }
if s.Sysctls != nil { if s.Sysctls != nil {
spec.Sysctls = make(map[string]string, len(s.Sysctls)) spec.Sysctls = make(map[string]string, len(s.Sysctls))
for k, v := range s.Sysctls { maps.Copy(spec.Sysctls, s.Sysctls)
spec.Sysctls[k] = v
}
} }
if s.Resources.Ulimits != nil { if s.Resources.Ulimits != nil {
spec.Resources.Ulimits = maps.Clone(s.Resources.Ulimits) spec.Resources.Ulimits = maps.Clone(s.Resources.Ulimits)
+2 -7
View File
@@ -9,12 +9,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// boolPtr is a convenience function to create a pointer to a uint64 value
// TODO: Make this a generic function that works for any type
func boolPtr(b bool) *bool {
return &b
}
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) { func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -212,6 +206,7 @@ func TestContainerSpec_Clone(t *testing.T) {
original := ContainerSpec{ original := ContainerSpec{
CapAdd: []string{"NET_ADMIN"}, CapAdd: []string{"NET_ADMIN"},
CapDrop: []string{"ALL"}, CapDrop: []string{"ALL"},
PidMode: "host",
Command: []string{"sh", "-c", "echo hello"}, Command: []string{"sh", "-c", "echo hello"},
Entrypoint: []string{"/bin/bash"}, Entrypoint: []string{"/bin/bash"},
Env: EnvVars{ Env: EnvVars{
@@ -219,7 +214,7 @@ func TestContainerSpec_Clone(t *testing.T) {
"BAZ": "qux", "BAZ": "qux",
}, },
Image: "nginx:latest", Image: "nginx:latest",
Init: boolPtr(true), Init: new(true),
LogDriver: &LogDriver{ LogDriver: &LogDriver{
Name: "json-file", Name: "json-file",
Options: map[string]string{ Options: map[string]string{
+3 -6
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"fmt" "fmt"
"maps"
"reflect" "reflect"
"slices" "slices"
"sort" "sort"
@@ -165,18 +166,14 @@ func (v *VolumeSpec) Clone() VolumeSpec {
driver := *v.VolumeOptions.Driver driver := *v.VolumeOptions.Driver
if driver.Options != nil { if driver.Options != nil {
driver.Options = make(map[string]string, len(v.VolumeOptions.Driver.Options)) driver.Options = make(map[string]string, len(v.VolumeOptions.Driver.Options))
for k, val := range v.VolumeOptions.Driver.Options { maps.Copy(driver.Options, v.VolumeOptions.Driver.Options)
driver.Options[k] = val
}
} }
opts.Driver = &driver opts.Driver = &driver
} }
if opts.Labels != nil { if opts.Labels != nil {
opts.Labels = make(map[string]string, len(v.VolumeOptions.Labels)) opts.Labels = make(map[string]string, len(v.VolumeOptions.Labels))
for k, val := range v.VolumeOptions.Labels { maps.Copy(opts.Labels, v.VolumeOptions.Labels)
opts.Labels[k] = val
}
} }
spec.VolumeOptions = &opts spec.VolumeOptions = &opts
+22
View File
@@ -0,0 +1,22 @@
package compose
import (
"github.com/compose-spec/compose-go/v2/types"
)
// ContextExtensionKey is the top-level Compose extension key for specifying the cluster context.
const ContextExtensionKey = "x-context"
// ClusterContext extracts the x-context value from the project's top-level extensions.
// Returns an empty string if x-context is not set.
func ClusterContext(project *types.Project) string {
v, ok := project.Extensions[ContextExtensionKey]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
+45
View File
@@ -0,0 +1,45 @@
package compose
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClusterContext(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "no x-context",
content: `
services:
web:
image: nginx
`,
want: "",
},
{
name: "x-context set",
content: `
x-context: prod
services:
web:
image: nginx
`,
want: "prod",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.content)
require.NoError(t, err)
assert.Equal(t, tt.want, ClusterContext(project))
})
}
}
+5 -7
View File
@@ -28,7 +28,7 @@ type Deployment struct {
SpecResolver *deploy.ServiceSpecResolver SpecResolver *deploy.ServiceSpecResolver
Strategy deploy.Strategy Strategy deploy.Strategy
state *scheduler.ClusterState state *scheduler.ClusterState
plan *operation.SequenceOperation plan *Plan
} }
func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) { func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) {
@@ -59,11 +59,11 @@ func NewDeploymentWithStrategy(ctx context.Context, cli Client, project *types.P
}, nil }, nil
} }
func (d *Deployment) Plan(ctx context.Context) (operation.SequenceOperation, error) { func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
if d.plan != nil { if d.plan != nil {
return *d.plan, nil return *d.plan, nil
} }
plan := operation.SequenceOperation{} var plan Plan
// Generate service specs for all services in the project. // Generate service specs for all services in the project.
var serviceSpecs []api.ServiceSpec var serviceSpecs []api.ServiceSpec
@@ -90,9 +90,7 @@ func (d *Deployment) Plan(ctx context.Context) (operation.SequenceOperation, err
if err != nil { if err != nil {
return plan, err return plan, err
} }
for _, op := range volumeOps { plan.Volumes = volumeOps
plan.Operations = append(plan.Operations, op)
}
for _, spec := range serviceSpecs { for _, spec := range serviceSpecs {
// TODO: properly handle depends_on conditions in the service deployment plan as the first operation. // TODO: properly handle depends_on conditions in the service deployment plan as the first operation.
@@ -105,7 +103,7 @@ func (d *Deployment) Plan(ctx context.Context) (operation.SequenceOperation, err
// Skip no-op (up-to-date) service plans. // Skip no-op (up-to-date) service plans.
if len(servicePlan.Operations) > 0 { if len(servicePlan.Operations) > 0 {
plan.Operations = append(plan.Operations, &servicePlan) plan.Services = append(plan.Services, &servicePlan)
} }
} }
+2 -2
View File
@@ -11,7 +11,7 @@ const MachinesExtensionKey = "x-machines"
type MachinesSource []string type MachinesSource []string
// DecodeMapstructure implements custom decoding for multiple input types // DecodeMapstructure implements custom decoding for multiple input types
func (m *MachinesSource) DecodeMapstructure(value interface{}) error { func (m *MachinesSource) DecodeMapstructure(value any) error {
switch v := value.(type) { switch v := value.(type) {
case *MachinesSource: case *MachinesSource:
// Handle case where compose-go passes a pointer to an already created instance // Handle case where compose-go passes a pointer to an already created instance
@@ -38,7 +38,7 @@ func (m *MachinesSource) DecodeMapstructure(value interface{}) error {
} }
*m = MachinesSource(machines) *m = MachinesSource(machines)
return nil return nil
case []interface{}: case []any:
// Support interface array that may come from YAML parsing // Support interface array that may come from YAML parsing
machineNames := make([]string, 0, len(v)) machineNames := make([]string, 0, len(v))
for i, machine := range v { for i, machine := range v {
+130
View File
@@ -0,0 +1,130 @@
package compose
import (
"context"
"fmt"
"strconv"
"strings"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
)
// Plan holds the compose-level deployment plan with volume and service operations.
type Plan struct {
Volumes []*operation.CreateVolumeOperation
Services []*deploy.ServicePlan
}
// IsEmpty returns true if the plan has no volume or service operations.
func (p *Plan) IsEmpty() bool {
return len(p.Volumes) == 0 && len(p.Services) == 0
}
// Format renders the entire deployment plan as a styled tree with a summary footer.
func (p *Plan) Format() string {
var out strings.Builder
// Format volume operations.
for _, op := range p.Volumes {
out.WriteString(op.Format())
out.WriteString("\n")
}
if len(p.Volumes) > 0 {
out.WriteString("\n")
}
// Format service plans.
for _, svcPlan := range p.Services {
out.WriteString(svcPlan.Format())
out.WriteString("\n")
}
// Format summary footer.
summary := p.formatSummary()
out.WriteString(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
out.WriteString("\n")
out.WriteString(summary)
out.WriteString("\n")
return out.String()
}
// formatSummary counts all operations across the plan and renders the summary footer.
func (p *Plan) formatSummary() string {
var createCount, startFirstCount, stopFirstCount, removeCount int
machines := make(map[string]struct{})
for _, op := range p.Volumes {
machines[op.MachineID] = struct{}{}
createCount++
}
for _, svcPlan := range p.Services {
for _, op := range svcPlan.Operations {
switch o := op.(type) {
case *operation.RunContainerOperation:
machines[o.MachineID] = struct{}{}
createCount++
case *operation.ReplaceContainerOperation:
machines[o.MachineID] = struct{}{}
if o.Order == api.UpdateOrderStopFirst {
stopFirstCount++
} else {
startFirstCount++
}
case *operation.RemoveContainerOperation:
machines[o.MachineID] = struct{}{}
removeCount++
case *operation.StopContainerOperation:
machines[o.MachineID] = struct{}{}
removeCount++
}
}
}
var parts []string
if createCount > 0 {
parts = append(parts,
tui.BoldGreen.Render(strconv.Itoa(createCount))+" "+tui.Green.Render("create"))
}
if startFirstCount > 0 {
parts = append(parts,
tui.BoldGreen.Render(strconv.Itoa(startFirstCount))+" "+tui.Green.Render("replace (start-first)"))
}
if stopFirstCount > 0 {
parts = append(parts,
tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)"))
}
if removeCount > 0 {
parts = append(parts,
tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove"))
}
machinesWord := "machines"
if len(machines) == 1 {
machinesWord = "machine"
}
parts = append(parts, fmt.Sprintf("across %s %s", tui.Bold.Render(strconv.Itoa(len(machines))), machinesWord))
sep := " " + tui.Faint.Render("·") + " "
return strings.Join(parts, sep)
}
// Execute runs all volume operations followed by all service operations.
func (p *Plan) Execute(ctx context.Context, cli operation.Client) error {
for _, op := range p.Volumes {
if err := op.Execute(ctx, cli); err != nil {
return err
}
}
for _, sp := range p.Services {
if err := sp.Execute(ctx, cli); err != nil {
return err
}
}
return nil
}
+4 -4
View File
@@ -53,6 +53,7 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
Healthcheck: healthcheckFromCompose(service.HealthCheck), Healthcheck: healthcheckFromCompose(service.HealthCheck),
Image: service.Image, Image: service.Image,
Init: service.Init, Init: service.Init,
PidMode: service.Pid,
Privileged: service.Privileged, Privileged: service.Privileged,
PullPolicy: pullPolicy, PullPolicy: pullPolicy,
Resources: resourcesFromCompose(service), Resources: resourcesFromCompose(service),
@@ -87,7 +88,7 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
if service.StopGracePeriod != nil { if service.StopGracePeriod != nil {
d := time.Duration(*service.StopGracePeriod) d := time.Duration(*service.StopGracePeriod)
spec.StopGracePeriod = &d spec.Container.StopGracePeriod = &d
} }
if service.Scale != nil { if service.Scale != nil {
@@ -177,6 +178,7 @@ func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
CPU: int64(service.CPUS * 1e9), CPU: int64(service.CPUS * 1e9),
Memory: int64(service.MemLimit), Memory: int64(service.MemLimit),
MemoryReservation: int64(service.MemReservation), MemoryReservation: int64(service.MemReservation),
SharedMemory: int64(service.ShmSize),
Ulimits: ulimitsFromCompose(service.Ulimits), Ulimits: ulimitsFromCompose(service.Ulimits),
} }
@@ -345,9 +347,7 @@ func dockerVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig, volume
func mergeLabels(labels ...types.Labels) types.Labels { func mergeLabels(labels ...types.Labels) types.Labels {
merged := types.Labels{} merged := types.Labels{}
for _, l := range labels { for _, l := range labels {
for k, v := range l { maps.Copy(merged, l)
merged[k] = v
}
} }
return merged return merged
} }
+3 -1
View File
@@ -127,12 +127,14 @@ func TestServiceSpecFromCompose(t *testing.T) {
"max-file": "3", "max-file": "3",
}, },
}, },
PidMode: "host",
Privileged: true, Privileged: true,
PullPolicy: api.PullPolicyAlways, PullPolicy: api.PullPolicyAlways,
Resources: api.ContainerResources{ Resources: api.ContainerResources{
CPU: 0.5 * api.Core, CPU: 0.5 * api.Core,
Memory: 100 * units.MiB, Memory: 100 * units.MiB,
MemoryReservation: 50 * units.MiB, MemoryReservation: 50 * units.MiB,
SharedMemory: 256 * units.MiB,
Ulimits: map[string]api.Ulimit{ Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 20000, Hard: 40000}, "nofile": {Soft: 20000, Hard: 40000},
"nproc": {Soft: 65535, Hard: 65535}, "nproc": {Soft: 65535, Hard: 65535},
@@ -145,6 +147,7 @@ func TestServiceSpecFromCompose(t *testing.T) {
{Count: -1, Capabilities: [][]string{{"gpu"}}}, {Count: -1, Capabilities: [][]string{{"gpu"}}},
}, },
}, },
StopGracePeriod: new(30 * time.Second),
Sysctls: map[string]string{ Sysctls: map[string]string{
"net.ipv4.ip_forward": "1", "net.ipv4.ip_forward": "1",
}, },
@@ -206,7 +209,6 @@ func TestServiceSpecFromCompose(t *testing.T) {
Machines: []string{"machine-1", "machine-2"}, Machines: []string{"machine-1", "machine-2"},
}, },
Replicas: 3, Replicas: 3,
StopGracePeriod: api.AsPtr(30 * time.Second),
UpdateConfig: api.UpdateConfig{ UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStopFirst, Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod, MonitorPeriod: &api.DefaultHealthMonitorPeriod,
+2
View File
@@ -6,6 +6,7 @@ services:
- ALL - ALL
command: ["nginx", "updated", "command"] command: ["nginx", "updated", "command"]
cpus: 0.5 cpus: 0.5
pid: host
deploy: deploy:
update_config: update_config:
order: stop-first order: stop-first
@@ -34,6 +35,7 @@ services:
max-file: 3 max-file: 3
mem_limit: 100M mem_limit: 100M
mem_reservation: 50M mem_reservation: 50M
shm_size: 268435456
privileged: true privileged: true
pull_policy: always pull_policy: always
scale: 3 scale: 3
+19
View File
@@ -47,6 +47,25 @@ func TestEvalContainerSpecChange_ContainerCapDrop(t *testing.T) {
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec)) assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec))
} }
func TestEvalContainerSpecChange_ContainerPidMode(t *testing.T) {
t.Parallel()
currentSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
},
}
newSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
PidMode: "host",
},
}
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(currentSpec, newSpec))
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec))
}
func TestEvalContainerSpecChange_ContainerResources(t *testing.T) { func TestEvalContainerSpecChange_ContainerResources(t *testing.T) {
t.Parallel() t.Parallel()
+237 -9
View File
@@ -4,7 +4,13 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strconv"
"strings"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/distribution/reference"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/operation" "github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
@@ -26,17 +32,239 @@ type Deployment struct {
Spec api.ServiceSpec Spec api.ServiceSpec
Strategy Strategy Strategy Strategy
cli Client cli Client
plan *Plan plan *ServicePlan
// state is an optional current and planned cluster state used for scheduling decisions. // state is an optional current and planned cluster state used for scheduling decisions.
state *scheduler.ClusterState state *scheduler.ClusterState
} }
type Plan struct { type ServicePlan struct {
ServiceID string ServiceID string
ServiceName string ServiceName string
// IsNewService indicates this plan creates a new service (first deployment) rather than updating an existing one.
IsNewService bool
// Spec is the desired service spec being deployed.
Spec api.ServiceSpec
operation.SequenceOperation operation.SequenceOperation
} }
// Format renders the service plan as a styled block with a spec diff and nested container operations.
func (sp *ServicePlan) Format() string {
// Determine service-level operation type and extract the old spec from container operations.
// Assume replace operations precede remove operations (rolling strategy) so the first replace operation
// (if exists) determines the old spec for the diff. Otherwise, fallback to the first remove operation.
var hasRun, hasRemove bool
var oldSpec *api.ServiceSpec
for _, op := range sp.Operations {
switch o := op.(type) {
case *operation.RunContainerOperation:
hasRun = true
case *operation.ReplaceContainerOperation:
if oldSpec == nil {
oldSpec = &o.OldContainer.ServiceSpec
}
case *operation.RemoveContainerOperation:
hasRemove = true
if oldSpec == nil {
oldSpec = &o.Container.ServiceSpec
}
}
}
// Service line modifier and verb.
var modifier, verb string
switch {
case sp.IsNewService:
modifier = tui.BoldGreen.Render("+")
verb = "create"
// TODO: when service removal via a deployment is supported, handle "remove" verb here as well.
default:
modifier = tui.BoldYellow.Render("~")
verb = "update"
}
var out strings.Builder
line := modifier + " " + verb + " service " + tui.NameStyle.Render(sp.ServiceName)
if sp.Spec.Mode == api.ServiceModeGlobal {
line += " " + tui.Faint.Render("(global)")
}
out.WriteString(line)
out.WriteString("\n")
// Build spec diff table: columns are [modifier, attribute, value or change].
// TODO: print diff for all changed attributes, not just image and replicas.
// Consider reusing the logic in EvalContainerSpecChange to return a structured diff.
specTable := table.New().
Border(lipgloss.Border{}).
BorderTop(false).BorderBottom(false).
BorderLeft(false).BorderRight(false).
BorderHeader(false).BorderColumn(false).
StyleFunc(func(row, col int) lipgloss.Style {
switch col {
case 0: // Modifier column.
return tui.Yellow.Width(2)
case 1: // Attribute column.
return tui.Faint.PaddingRight(1)
default:
return lipgloss.NewStyle()
}
})
// Image row.
if oldSpec == nil {
if sp.IsNewService {
specTable.Row("", "image:", tui.FormatImage(sp.Spec.Container.Image, tui.Green))
} else {
specTable.Row("", "image:", tui.FormatImage(sp.Spec.Container.Image, lipgloss.NewStyle()))
}
} else {
mod := ""
if oldSpec.Container.Image != sp.Spec.Container.Image {
mod = "~"
}
specTable.Row(mod, "image:", formatImageDiff(oldSpec.Container.Image, sp.Spec.Container.Image))
}
// Replicas row for replicated services.
if sp.Spec.Mode == api.ServiceModeReplicated {
replicasStr := fmt.Sprintf("%d", sp.Spec.Replicas)
if sp.IsNewService {
if sp.Spec.Replicas > 1 {
specTable.Row("", "replicas:", tui.Green.Render(replicasStr))
}
} else if sp.Spec.Replicas > 1 || hasRun || hasRemove {
mod := ""
if hasRun || hasRemove {
mod = "~"
replicasStr = tui.Green.Render(replicasStr)
}
specTable.Row(mod, "replicas:", replicasStr)
}
}
// Stack " │ " tree prefixes vertically, then join horizontally with the table.
tableStr := specTable.String()
treePrefix := tui.Faint.Render(" │ ")
treeColRows := make([]string, specTable.GetData().Rows())
for i := range treeColRows {
treeColRows[i] = treePrefix
}
treeCol := lipgloss.JoinVertical(lipgloss.Left, treeColRows...)
out.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, treeCol, tableStr))
out.WriteString("\n")
// Blank separator line before container operations.
out.WriteString(tui.Faint.Render(" │"))
out.WriteString("\n")
// Format each container operation.
opsCount := len(sp.Operations)
for i, op := range sp.Operations {
connector := tui.Faint.Render(" ├──")
if i == opsCount-1 {
connector = tui.Faint.Render(" ╰──")
}
out.WriteString(connector + " " + op.Format())
out.WriteString("\n")
}
return out.String()
}
// FormatSummary counts operations in the service plan and renders a styled summary line.
func (sp *ServicePlan) FormatSummary() string {
var createCount, startFirstCount, stopFirstCount, removeCount int
machines := make(map[string]struct{})
for _, op := range sp.Operations {
switch o := op.(type) {
case *operation.RunContainerOperation:
machines[o.MachineID] = struct{}{}
createCount++
case *operation.ReplaceContainerOperation:
machines[o.MachineID] = struct{}{}
if o.Order == api.UpdateOrderStopFirst {
stopFirstCount++
} else {
startFirstCount++
}
case *operation.RemoveContainerOperation:
machines[o.MachineID] = struct{}{}
removeCount++
case *operation.StopContainerOperation:
machines[o.MachineID] = struct{}{}
removeCount++
}
}
var parts []string
if createCount > 0 {
parts = append(parts,
tui.BoldGreen.Render(strconv.Itoa(createCount))+" "+tui.Green.Render("create"))
}
if startFirstCount > 0 {
parts = append(parts,
tui.BoldGreen.Render(strconv.Itoa(startFirstCount))+" "+tui.Green.Render("replace (start-first)"))
}
if stopFirstCount > 0 {
parts = append(parts,
tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)"))
}
if removeCount > 0 {
parts = append(parts,
tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove"))
}
machinesWord := "machines"
if len(machines) == 1 {
machinesWord = "machine"
}
parts = append(parts, fmt.Sprintf("across %s %s", tui.Bold.Render(strconv.Itoa(len(machines))), machinesWord))
sep := " " + tui.Faint.Render("·") + " "
return strings.Join(parts, sep)
}
// formatImageDiff formats the image for display. If oldImage is empty, it formats newImage as a new (green) value.
// Otherwise, it renders the diff between oldImage and newImage.
func formatImageDiff(oldImage, newImage string) string {
if oldImage == "" {
return tui.FormatImage(newImage, tui.Green)
}
if oldImage == newImage {
return tui.FormatImage(newImage, lipgloss.NewStyle())
}
oldRef, _ := reference.ParseDockerRef(oldImage)
newRef, _ := reference.ParseDockerRef(newImage)
// If either uses a digest, show full old → new.
_, oldDigested := oldRef.(reference.Digested)
_, newDigested := newRef.(reference.Digested)
if oldDigested || newDigested {
return tui.FormatImage(oldImage, tui.Red) + " " +
tui.Faint.Render("→") + " " +
tui.FormatImage(newImage, tui.Green)
}
// If repos match and both are tagged, show only tag diff.
oldTagged, oldOk := oldRef.(reference.NamedTagged)
newTagged, newOk := newRef.(reference.NamedTagged)
if oldOk && newOk && reference.FamiliarName(oldRef) == reference.FamiliarName(newRef) {
return reference.FamiliarName(newRef) +
tui.Faint.Render(":") +
tui.Red.Render(oldTagged.Tag()) + " " +
tui.Faint.Render("→") + " " +
tui.Green.Render(newTagged.Tag())
}
// Different repos: full old → new.
return tui.FormatImage(oldImage, tui.Red) + " " +
tui.Faint.Render("→") + " " +
tui.FormatImage(newImage, tui.Green)
}
// NewDeployment creates a new deployment for the given service specification. // NewDeployment creates a new deployment for the given service specification.
// If strategy is nil, a default RollingStrategy will be used. // If strategy is nil, a default RollingStrategy will be used.
func NewDeployment(cli Client, spec api.ServiceSpec, strategy Strategy) *Deployment { func NewDeployment(cli Client, spec api.ServiceSpec, strategy Strategy) *Deployment {
@@ -63,19 +291,19 @@ func NewDeploymentWithClusterState(
// Plan returns a plan of operations to reconcile the service to the desired state. // Plan returns a plan of operations to reconcile the service to the desired state.
// If a plan has already been created, the same plan will be returned. // If a plan has already been created, the same plan will be returned.
func (d *Deployment) Plan(ctx context.Context) (Plan, error) { func (d *Deployment) Plan(ctx context.Context) (ServicePlan, error) {
if d.plan != nil { if d.plan != nil {
return *d.plan, nil return *d.plan, nil
} }
// Validate the user-provided spec before resolving it. // Validate the user-provided spec before resolving it.
if err := d.Validate(ctx); err != nil { if err := d.Validate(ctx); err != nil {
return Plan{}, fmt.Errorf("invalid deployment: %w", err) return ServicePlan{}, fmt.Errorf("invalid deployment: %w", err)
} }
clusterDomain, err := d.cli.GetDomain(ctx) clusterDomain, err := d.cli.GetDomain(ctx)
if err != nil && !errors.Is(err, api.ErrNotFound) { if err != nil && !errors.Is(err, api.ErrNotFound) {
return Plan{}, fmt.Errorf("get cluster domain: %w", err) return ServicePlan{}, fmt.Errorf("get cluster domain: %w", err)
} }
specResolver := &ServiceSpecResolver{ specResolver := &ServiceSpecResolver{
// If the domain is not found (not reserved), an empty domain is used for the resolver. // If the domain is not found (not reserved), an empty domain is used for the resolver.
@@ -84,19 +312,19 @@ func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
resolvedSpec, err := specResolver.Resolve(d.Spec) resolvedSpec, err := specResolver.Resolve(d.Spec)
if err != nil { if err != nil {
return Plan{}, fmt.Errorf("resolve service spec: %w", err) return ServicePlan{}, fmt.Errorf("resolve service spec: %w", err)
} }
if d.state == nil { if d.state == nil {
d.state, err = scheduler.InspectClusterState(ctx, d.cli) d.state, err = scheduler.InspectClusterState(ctx, d.cli)
if err != nil { if err != nil {
return Plan{}, fmt.Errorf("inspect cluster state: %w", err) return ServicePlan{}, fmt.Errorf("inspect cluster state: %w", err)
} }
} }
plan, err := d.Strategy.Plan(d.state, d.Service, resolvedSpec) plan, err := d.Strategy.Plan(d.state, d.Service, resolvedSpec)
if err != nil { if err != nil {
return Plan{}, fmt.Errorf("create plan using %s strategy: %w", d.Strategy.Type(), err) return ServicePlan{}, fmt.Errorf("create plan using %s strategy: %w", d.Strategy.Type(), err)
} }
d.plan = &plan d.plan = &plan
@@ -143,7 +371,7 @@ func (d *Deployment) Validate(ctx context.Context) error {
// It will create a new plan if one hasn't been created yet. The deployment will either create a new service or update // It will create a new plan if one hasn't been created yet. The deployment will either create a new service or update
// the existing one to match the desired specification. // the existing one to match the desired specification.
// TODO: forbid to run the same deployment more than once. // TODO: forbid to run the same deployment more than once.
func (d *Deployment) Run(ctx context.Context) (Plan, error) { func (d *Deployment) Run(ctx context.Context) (ServicePlan, error) {
plan, err := d.Plan(ctx) plan, err := d.Plan(ctx)
if err != nil { if err != nil {
return plan, fmt.Errorf("create plan: %w", err) return plan, fmt.Errorf("create plan: %w", err)
+48 -14
View File
@@ -8,6 +8,7 @@ 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/docker/docker/pkg/stringid" "github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
) )
@@ -16,6 +17,8 @@ type RunContainerOperation struct {
ServiceID string ServiceID string
Spec api.ServiceSpec Spec api.ServiceSpec
MachineID string MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
// SkipHealthMonitor skips the monitoring period and health checks after starting a container. // SkipHealthMonitor skips the monitoring period and health checks after starting a container.
SkipHealthMonitor bool SkipHealthMonitor bool
} }
@@ -42,9 +45,12 @@ func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error {
return nil return nil
} }
func (o *RunContainerOperation) Format(resolver NameResolver) string { func (o *RunContainerOperation) Format() string {
machineName := resolver.MachineName(o.MachineID) return tui.BoldGreen.Render("+") + " " +
return fmt.Sprintf("%s: Run container [image=%s]", machineName, o.Spec.Container.Image) tui.Faint.Render("run container") + " " +
o.Spec.Name + " " +
tui.Faint.Render("on") + " " +
o.MachineName
} }
func (o *RunContainerOperation) String() string { func (o *RunContainerOperation) String() string {
@@ -57,6 +63,8 @@ type StopContainerOperation struct {
ServiceID string ServiceID string
ContainerID string ContainerID string
MachineID string MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
StopGracePeriod *time.Duration StopGracePeriod *time.Duration
} }
@@ -67,10 +75,15 @@ func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error
return nil return nil
} }
func (o *StopContainerOperation) Format(resolver NameResolver) string { func (o *StopContainerOperation) Format() string {
machineName := resolver.MachineName(o.MachineID) // TODO: pass service name to format the display name consistently with other operations.
return fmt.Sprintf("%s: Stop container [id=%s name=%s]", machineName, displayName := stringid.TruncateID(o.ContainerID)
o.ContainerID[:12], resolver.ContainerName(o.ContainerID))
return tui.BoldRed.Render("-") + " " +
tui.Faint.Render("stop container") + " " +
displayName + " " +
tui.Faint.Render("on") + " " +
o.MachineName
} }
func (o *StopContainerOperation) String() string { func (o *StopContainerOperation) String() string {
@@ -81,6 +94,8 @@ func (o *StopContainerOperation) String() string {
// RemoveContainerOperation stops and removes a container from a specific machine. // RemoveContainerOperation stops and removes a container from a specific machine.
type RemoveContainerOperation struct { type RemoveContainerOperation struct {
MachineID string MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
Container api.ServiceContainer Container api.ServiceContainer
StopGracePeriod *time.Duration StopGracePeriod *time.Duration
} }
@@ -101,10 +116,14 @@ func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) erro
return nil return nil
} }
func (o *RemoveContainerOperation) Format(resolver NameResolver) string { func (o *RemoveContainerOperation) Format() string {
machineName := resolver.MachineName(o.MachineID) displayName := o.Container.ServiceSpec.Name + tui.Faint.Render("/") + o.Container.ShortID()
return fmt.Sprintf("%s: Remove container [id=%s image=%s]",
machineName, o.Container.ShortID(), o.Container.Config.Image) return tui.BoldRed.Render("-") + " " +
tui.Faint.Render("remove container") + " " +
displayName + " " +
tui.Faint.Render("on") + " " +
o.MachineName
} }
func (o *RemoveContainerOperation) String() string { func (o *RemoveContainerOperation) String() string {
@@ -119,6 +138,8 @@ type ReplaceContainerOperation struct {
ServiceID string ServiceID string
Spec api.ServiceSpec Spec api.ServiceSpec
MachineID string MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
OldContainer api.ServiceContainer OldContainer api.ServiceContainer
// Order specifies the update order: "start-first" or "stop-first". // Order specifies the update order: "start-first" or "stop-first".
Order string Order string
@@ -207,9 +228,22 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err
return nil return nil
} }
func (o *ReplaceContainerOperation) Format(resolver NameResolver) string { func (o *ReplaceContainerOperation) Format() string {
return fmt.Sprintf("%s: Replace container [id=%s image=%s order=%s]", displayName := o.Spec.Name + tui.Faint.Render("/") + o.OldContainer.ShortID()
resolver.MachineName(o.MachineID), o.OldContainer.ShortID(), o.Spec.Container.Image, o.Order)
if o.Order == api.UpdateOrderStopFirst {
return tui.BoldYellow.Render("-") + tui.Yellow.Render("/") + tui.BoldYellow.Render("+") + " " +
tui.Faint.Render("replace container") + " " +
displayName + " " +
tui.Faint.Render("on") + " " +
o.MachineName + " " +
tui.Yellow.Render("(stop-first)")
}
return tui.BoldGreen.Render("+") + tui.Green.Render("/") + tui.BoldGreen.Render("-") + " " +
tui.Faint.Render("replace container") + " " +
displayName + " " +
tui.Faint.Render("on") + " " +
o.MachineName
} }
func (o *ReplaceContainerOperation) String() string { func (o *ReplaceContainerOperation) String() string {
+1 -8
View File
@@ -12,8 +12,7 @@ type Operation interface {
// Execute performs the operation using the provided client. // Execute performs the operation using the provided client.
Execute(ctx context.Context, cli Client) error Execute(ctx context.Context, cli Client) error
// Format returns a human-readable representation of the operation. // Format returns a human-readable representation of the operation.
// TODO: get rid of the resolver and assign the required names for formatting in the operation itself. Format() string
Format(resolver NameResolver) string
String() string String() string
} }
@@ -22,9 +21,3 @@ type Client interface {
api.ContainerClient api.ContainerClient
api.VolumeClient api.VolumeClient
} }
// NameResolver resolves machine and container IDs to their names.
type NameResolver interface {
MachineName(machineID string) string
ContainerName(containerID string) string
}
+4 -4
View File
@@ -20,13 +20,13 @@ func (o *SequenceOperation) Execute(ctx context.Context, cli Client) error {
return nil return nil
} }
func (o *SequenceOperation) Format(resolver NameResolver) string { func (o *SequenceOperation) Format() string {
ops := make([]string, len(o.Operations)) lines := make([]string, len(o.Operations))
for i, op := range o.Operations { for i, op := range o.Operations {
ops[i] = "- " + op.Format(resolver) lines[i] = op.Format()
} }
return strings.Join(ops, "\n") return strings.Join(lines, "\n")
} }
func (o *SequenceOperation) String() string { func (o *SequenceOperation) String() string {
+8 -3
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
) )
@@ -12,7 +13,7 @@ import (
type CreateVolumeOperation struct { type CreateVolumeOperation struct {
VolumeSpec api.VolumeSpec VolumeSpec api.VolumeSpec
MachineID string MachineID string
// MachineName is used for formatting the operation output only. // MachineName is used for formatting the operation as part of the deployment plan.
MachineName string MachineName string
} }
@@ -39,8 +40,12 @@ func (o *CreateVolumeOperation) Execute(ctx context.Context, cli Client) error {
return nil return nil
} }
func (o *CreateVolumeOperation) Format(_ NameResolver) string { func (o *CreateVolumeOperation) Format() string {
return fmt.Sprintf("%s: Create volume [name=%s]", o.MachineName, o.VolumeSpec.DockerVolumeName()) return fmt.Sprintf("%s create volume %s %s %s",
tui.BoldGreen.Render("+"),
tui.NameStyle.Render(o.VolumeSpec.DockerVolumeName()),
tui.Faint.Render("on"),
o.MachineName)
} }
func (o *CreateVolumeOperation) String() string { func (o *CreateVolumeOperation) String() string {
+60 -25
View File
@@ -20,7 +20,7 @@ type Strategy interface {
// Plan returns the operation to reconcile the service to the desired state. // Plan returns the operation to reconcile the service to the desired state.
// If the service does not exist (new deployment), svc will be nil. state provides the current and planned state // If the service does not exist (new deployment), svc will be nil. state provides the current and planned state
// of the cluster for scheduling decisions. // of the cluster for scheduling decisions.
Plan(state *scheduler.ClusterState, svc *api.Service, spec api.ServiceSpec) (Plan, error) Plan(state *scheduler.ClusterState, svc *api.Service, spec api.ServiceSpec) (ServicePlan, error)
} }
// RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time // RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time
@@ -40,9 +40,9 @@ func (s *RollingStrategy) Type() string {
return "rolling" return "rolling"
} }
func (s *RollingStrategy) Plan(state *scheduler.ClusterState, svc *api.Service, spec api.ServiceSpec) (Plan, error) { func (s *RollingStrategy) Plan(state *scheduler.ClusterState, svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
if state == nil { if state == nil {
return Plan{}, fmt.Errorf("cluster state must be provided") return ServicePlan{}, fmt.Errorf("cluster state must be provided")
} }
s.state = state s.state = state
@@ -53,7 +53,7 @@ func (s *RollingStrategy) Plan(state *scheduler.ClusterState, svc *api.Service,
case api.ServiceModeGlobal: case api.ServiceModeGlobal:
return s.planGlobal(svc, spec) return s.planGlobal(svc, spec)
default: default:
return Plan{}, fmt.Errorf("unsupported service mode: '%s'", spec.Mode) return ServicePlan{}, fmt.Errorf("unsupported service mode: '%s'", spec.Mode)
} }
} }
@@ -61,12 +61,18 @@ func (s *RollingStrategy) Plan(state *scheduler.ClusterState, svc *api.Service,
// For replicated services, we want to maintain a specific number of containers (replicas) across the available machines // For replicated services, we want to maintain a specific number of containers (replicas) across the available machines
// in the cluster. // in the cluster.
// TODO: schedule containers only on machines that contain the image if pull policy is set to 'never'. // TODO: schedule containers only on machines that contain the image if pull policy is set to 'never'.
func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec) (Plan, error) { func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
plan, err := newEmptyPlan(svc, spec) plan, err := newEmptyServicePlan(svc, spec)
if err != nil { if err != nil {
return plan, err return plan, err
} }
// Build a machine ID to name map from the cluster state to resolve IDs for operations.
machineNames := make(map[string]string, len(s.state.Machines))
for _, m := range s.state.Machines {
machineNames[m.Info.Id] = m.Info.Name
}
sched := scheduler.NewServiceScheduler(s.state, spec) sched := scheduler.NewServiceScheduler(s.state, spec)
// TODO: return a detailed report on required constraints and which ones are satisfied? // TODO: return a detailed report on required constraints and which ones are satisfied?
availableMachines, err := sched.EligibleMachines() availableMachines, err := sched.EligibleMachines()
@@ -151,6 +157,7 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
ServiceID: plan.ServiceID, ServiceID: plan.ServiceID,
Spec: spec, Spec: spec,
MachineID: m.Id, MachineID: m.Id,
MachineName: m.Name,
SkipHealthMonitor: s.SkipHealthMonitor, SkipHealthMonitor: s.SkipHealthMonitor,
}) })
continue continue
@@ -172,10 +179,11 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
ServiceID: plan.ServiceID, ServiceID: plan.ServiceID,
Spec: spec, Spec: spec,
MachineID: m.Id, MachineID: m.Id,
MachineName: m.Name,
OldContainer: ctr, OldContainer: ctr,
Order: order, Order: order,
SkipHealthMonitor: s.SkipHealthMonitor, SkipHealthMonitor: s.SkipHealthMonitor,
StopGracePeriod: spec.StopGracePeriod, StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
@@ -184,8 +192,9 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
for _, c := range containers { for _, c := range containers {
plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{ plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
MachineID: mid, MachineID: mid,
MachineName: machineNames[mid],
Container: c, Container: c,
StopGracePeriod: spec.StopGracePeriod, StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
} }
@@ -198,12 +207,18 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
// possible. If the new container would have port conflicts with the existing one, the old container is removed first. // possible. If the new container would have port conflicts with the existing one, the old container is removed first.
// It handles multiple containers per machine (though this should not occur in normal operation) and skips machines // It handles multiple containers per machine (though this should not occur in normal operation) and skips machines
// that are down. // that are down.
func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Plan, error) { func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
plan, err := newEmptyPlan(svc, spec) plan, err := newEmptyServicePlan(svc, spec)
if err != nil { if err != nil {
return plan, err return plan, err
} }
// Build a machine ID to name map from the cluster state to resolve IDs for operations.
machineNames := make(map[string]string, len(s.state.Machines))
for _, m := range s.state.Machines {
machineNames[m.Info.Id] = m.Info.Name
}
// Map machineID to service containers on that machine. For the global mode, there should be at most one // Map machineID to service containers on that machine. For the global mode, there should be at most one
// container per machine but we use a slice to handle multiple containers that may exist due to a bug // container per machine but we use a slice to handle multiple containers that may exist due to a bug
// or interruption in the previous deployment. // or interruption in the previous deployment.
@@ -223,7 +238,13 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
for _, m := range availableMachines { for _, m := range availableMachines {
containers := containersOnMachine[m.Info.Id] containers := containersOnMachine[m.Info.Id]
ops, err := reconcileGlobalContainer( ops, err := reconcileGlobalContainer(
containers, spec, plan.ServiceID, m.Info.Id, s.ForceRecreate, s.SkipHealthMonitor) containers,
spec,
plan.ServiceID,
m.Info,
s.ForceRecreate,
s.SkipHealthMonitor,
)
if err != nil { if err != nil {
return plan, err return plan, err
} }
@@ -237,8 +258,9 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
for _, c := range containers { for _, c := range containers {
plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{ plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
MachineID: c.MachineID, MachineID: c.MachineID,
MachineName: machineNames[c.MachineID],
Container: c.Container, Container: c.Container,
StopGracePeriod: spec.StopGracePeriod, StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
} }
@@ -250,7 +272,10 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
// It ensures exactly one container with the desired spec is running on the machine by creating a new container and // It ensures exactly one container with the desired spec is running on the machine by creating a new container and
// removing old ones. If there is a host port conflict, it stops the old container before starting a new one. // removing old ones. If there is a host port conflict, it stops the old container before starting a new one.
func reconcileGlobalContainer( func reconcileGlobalContainer(
containers []api.MachineServiceContainer, spec api.ServiceSpec, serviceID, machineID string, containers []api.MachineServiceContainer,
spec api.ServiceSpec,
serviceID string,
machine *pb.MachineInfo,
forceRecreate, skipHealthCheck bool, forceRecreate, skipHealthCheck bool,
) ([]operation.Operation, error) { ) ([]operation.Operation, error) {
var ops []operation.Operation var ops []operation.Operation
@@ -260,7 +285,8 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.RunContainerOperation{ ops = append(ops, &operation.RunContainerOperation{
ServiceID: serviceID, ServiceID: serviceID,
Spec: spec, Spec: spec,
MachineID: machineID, MachineID: machine.Id,
MachineName: machine.Name,
SkipHealthMonitor: skipHealthCheck, SkipHealthMonitor: skipHealthCheck,
}) })
return ops, nil return ops, nil
@@ -290,8 +316,9 @@ func reconcileGlobalContainer(
} }
ops = append(ops, &operation.RemoveContainerOperation{ ops = append(ops, &operation.RemoveContainerOperation{
MachineID: old.MachineID, MachineID: old.MachineID,
MachineName: machine.Name,
Container: old.Container, Container: old.Container,
StopGracePeriod: spec.StopGracePeriod, StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
break break
@@ -325,8 +352,9 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.StopContainerOperation{ ops = append(ops, &operation.StopContainerOperation{
ServiceID: serviceID, ServiceID: serviceID,
ContainerID: c.Container.ID, ContainerID: c.Container.ID,
MachineID: machineID, MachineID: machine.Id,
StopGracePeriod: spec.StopGracePeriod, MachineName: machine.Name,
StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
} }
@@ -336,11 +364,12 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.ReplaceContainerOperation{ ops = append(ops, &operation.ReplaceContainerOperation{
ServiceID: serviceID, ServiceID: serviceID,
Spec: spec, Spec: spec,
MachineID: machineID, MachineID: machine.Id,
MachineName: machine.Name,
OldContainer: containerToReplace.Container, OldContainer: containerToReplace.Container,
Order: order, Order: order,
SkipHealthMonitor: skipHealthCheck, SkipHealthMonitor: skipHealthCheck,
StopGracePeriod: spec.StopGracePeriod, StopGracePeriod: spec.Container.StopGracePeriod,
}) })
// Remove any other containers (there shouldn't be any in normal operation). // Remove any other containers (there shouldn't be any in normal operation).
@@ -350,8 +379,9 @@ func reconcileGlobalContainer(
} }
ops = append(ops, &operation.RemoveContainerOperation{ ops = append(ops, &operation.RemoveContainerOperation{
MachineID: c.MachineID, MachineID: c.MachineID,
MachineName: machine.Name,
Container: c.Container, Container: c.Container,
StopGracePeriod: spec.StopGracePeriod, StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
} else { } else {
@@ -359,14 +389,16 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.RunContainerOperation{ ops = append(ops, &operation.RunContainerOperation{
ServiceID: serviceID, ServiceID: serviceID,
Spec: spec, Spec: spec,
MachineID: machineID, MachineID: machine.Id,
MachineName: machine.Name,
SkipHealthMonitor: skipHealthCheck, SkipHealthMonitor: skipHealthCheck,
}) })
for _, c := range containers { for _, c := range containers {
ops = append(ops, &operation.RemoveContainerOperation{ ops = append(ops, &operation.RemoveContainerOperation{
MachineID: c.MachineID, MachineID: c.MachineID,
MachineName: machine.Name,
Container: c.Container, Container: c.Container,
StopGracePeriod: spec.StopGracePeriod, StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
} }
@@ -403,15 +435,18 @@ func determineUpdateOrder(oldContainer api.ServiceContainer, spec api.ServiceSpe
return api.UpdateOrderStartFirst return api.UpdateOrderStartFirst
} }
// newEmptyPlan creates a new empty plan for a service deployment with initialised service ID and name. // newEmptyServicePlan creates a new empty plan for a service deployment with initialised service ID and name.
func newEmptyPlan(svc *api.Service, spec api.ServiceSpec) (Plan, error) { func newEmptyServicePlan(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
var plan Plan plan := ServicePlan{
Spec: spec,
}
// Generate a new service ID for the initial service deployment if it doesn't exist yet. // Generate a new service ID for the initial service deployment if it doesn't exist yet.
if svc != nil { if svc != nil {
plan.ServiceID = svc.ID plan.ServiceID = svc.ID
plan.ServiceName = svc.Name plan.ServiceName = svc.Name
} else { } else {
plan.IsNewService = true
var err error var err error
plan.ServiceID, err = secret.NewID() plan.ServiceID, err = secret.NewID()
if err != nil { if err != nil {
+14 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts" "github.com/google/go-cmp/cmp/cmpopts"
"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/deploy/operation" "github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -317,6 +318,7 @@ func TestReconcileGlobalContainer(t *testing.T) {
&operation.RunContainerOperation{ &operation.RunContainerOperation{
ServiceID: "service-1", ServiceID: "service-1",
MachineID: "machine-1", MachineID: "machine-1",
MachineName: "machine-1",
}, },
}, },
}, },
@@ -335,6 +337,7 @@ func TestReconcileGlobalContainer(t *testing.T) {
&operation.ReplaceContainerOperation{ &operation.ReplaceContainerOperation{
ServiceID: "service-1", ServiceID: "service-1",
MachineID: "machine-1", MachineID: "machine-1",
MachineName: "machine-1",
OldContainer: container1, OldContainer: container1,
Order: api.UpdateOrderStopFirst, Order: api.UpdateOrderStopFirst,
}, },
@@ -358,15 +361,18 @@ func TestReconcileGlobalContainer(t *testing.T) {
ServiceID: "service-1", ServiceID: "service-1",
ContainerID: "container-2", ContainerID: "container-2",
MachineID: "machine-1", MachineID: "machine-1",
MachineName: "machine-1",
}, },
&operation.ReplaceContainerOperation{ &operation.ReplaceContainerOperation{
ServiceID: "service-1", ServiceID: "service-1",
MachineID: "machine-1", MachineID: "machine-1",
MachineName: "machine-1",
OldContainer: container1, OldContainer: container1,
Order: api.UpdateOrderStopFirst, Order: api.UpdateOrderStopFirst,
}, },
&operation.RemoveContainerOperation{ &operation.RemoveContainerOperation{
MachineID: "machine-1", MachineID: "machine-1",
MachineName: "machine-1",
Container: container2WithPort9090, Container: container2WithPort9090,
}, },
}, },
@@ -388,11 +394,13 @@ func TestReconcileGlobalContainer(t *testing.T) {
&operation.ReplaceContainerOperation{ &operation.ReplaceContainerOperation{
ServiceID: "service-1", ServiceID: "service-1",
MachineID: "machine-1", MachineID: "machine-1",
MachineName: "machine-1",
OldContainer: container1, OldContainer: container1,
Order: api.UpdateOrderStopFirst, Order: api.UpdateOrderStopFirst,
}, },
&operation.RemoveContainerOperation{ &operation.RemoveContainerOperation{
MachineID: "machine-1", MachineID: "machine-1",
MachineName: "machine-1",
Container: container2WithPort3000, Container: container2WithPort3000,
}, },
}, },
@@ -402,7 +410,12 @@ func TestReconcileGlobalContainer(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
ops, err := reconcileGlobalContainer( ops, err := reconcileGlobalContainer(
tt.containers, tt.spec, "service-1", "machine-1", tt.forceRecreate, false, tt.containers,
tt.spec,
"service-1",
&pb.MachineInfo{Id: "machine-1", Name: "machine-1"},
tt.forceRecreate,
false,
) )
assert.NoError(t, err) assert.NoError(t, err)
assertOperationsEqual(t, tt.expectedOps, ops) assertOperationsEqual(t, tt.expectedOps, ops)
+3 -5
View File
@@ -12,7 +12,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/containerd/errdefs" "github.com/containerd/errdefs"
"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"
@@ -474,12 +474,10 @@ func toPushProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
percent := 0 percent := 0
if jm.Progress.Total > 0 { if jm.Progress.Total > 0 {
percent = int(jm.Progress.Current * 100 / jm.Progress.Total) percent = min(
// Cap percent at 100 to prevent index out of bounds in progress display. // Cap percent at 100 to prevent index out of bounds in progress display.
// Docker can report Current > Total in some cases (e.g., compression). // Docker can report Current > Total in some cases (e.g., compression).
if percent > 100 { int(jm.Progress.Current*100/jm.Progress.Total), 100)
percent = 100
}
} }
switch jm.Status { switch jm.Status {
+1 -1
View File
@@ -264,7 +264,7 @@ func TestLogMerger_UnevenStreams(t *testing.T) {
baseTime := time.Now() baseTime := time.Now()
// Stream 1 sends many entries quickly (0ms - 104ms). // Stream 1 sends many entries quickly (0ms - 104ms).
for i := 0; i < numFastEntries; i++ { for i := range numFastEntries {
ch1 <- testEntry(api.LogStreamStdout, baseTime.Add(time.Duration(i)*time.Millisecond), "fast") ch1 <- testEntry(api.LogStreamStdout, baseTime.Add(time.Duration(i)*time.Millisecond), "fast")
} }
-14
View File
@@ -1,14 +0,0 @@
package client
import (
"fmt"
"os"
"github.com/charmbracelet/lipgloss"
)
func PrintWarning(msg string) {
style := lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // Bright yellow.
styledMsg := style.Render(fmt.Sprintf("WARNING: %s", msg))
fmt.Fprintln(os.Stderr, styledMsg)
}
-54
View File
@@ -1,54 +0,0 @@
package client
import (
"context"
"fmt"
"github.com/psviderski/uncloud/pkg/api"
)
// MapNameResolver resolves machine and container IDs to their names using a static map.
type MapNameResolver struct {
machines map[string]string
containers map[string]string
}
func NewNameResolver(machines, containers map[string]string) *MapNameResolver {
return &MapNameResolver{
machines: machines,
containers: containers,
}
}
func (r *MapNameResolver) MachineName(machineID string) string {
if name, ok := r.machines[machineID]; ok {
return name
}
return machineID
}
func (r *MapNameResolver) ContainerName(containerID string) string {
if name, ok := r.containers[containerID]; ok {
return name
}
return containerID
}
// ServiceOperationNameResolver returns a machine and container name resolver for a service that can be used to format
// deployment operations.
func (cli *Client) ServiceOperationNameResolver(ctx context.Context, svc api.Service) (*MapNameResolver, error) {
machines, err := cli.ListMachines(ctx, nil)
if err != nil {
return nil, fmt.Errorf("list machines: %w", err)
}
machineNames := make(map[string]string, len(machines))
for _, m := range machines {
machineNames[m.Machine.Id] = m.Machine.Name
}
containerNames := make(map[string]string, len(svc.Containers))
for _, c := range svc.Containers {
containerNames[c.Container.ID] = c.Container.Name
}
return NewNameResolver(machineNames, containerNames), nil
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/containerd/errdefs" "github.com/containerd/errdefs"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
) )
@@ -70,7 +71,7 @@ func (cli *Client) ListVolumes(ctx context.Context, filter *api.VolumeFilter) ([
for _, mv := range machineVolumes { for _, mv := range machineVolumes {
if mv.Metadata != nil && mv.Metadata.Error != "" { if mv.Metadata != nil && mv.Metadata.Error != "" {
// TODO: return failed machines in the response. // TODO: return failed machines in the response.
PrintWarning(fmt.Sprintf("failed to list volumes on machine '%s': %s", tui.PrintWarning(fmt.Sprintf("failed to list volumes on machine '%s': %s",
mv.Metadata.Machine, mv.Metadata.Error)) mv.Metadata.Machine, mv.Metadata.Error))
continue continue
} }
+5 -3
View File
@@ -39,7 +39,8 @@ func TestComposeConfigs(t *testing.T) {
plan, err := deploy.Plan(ctx) plan, err := deploy.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 1, "Expected 1 service deployment") assert.Len(t, plan.Services, 1, "Expected 1 service deployment")
assert.Empty(t, plan.Volumes, "Expected no volume operations")
err = deploy.Run(ctx) err = deploy.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -93,7 +94,7 @@ func TestComposeConfigs(t *testing.T) {
plan, err = deploy.Plan(ctx) plan, err = deploy.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 0, "Expected no new operations after configs deployment") assert.True(t, plan.IsEmpty(), "Expected no new operations after configs deployment")
// Verify the config files are actually created in the container and contain expected content // Verify the config files are actually created in the container and contain expected content
containerName := svc.Containers[0].Container.Name containerName := svc.Containers[0].Container.Name
@@ -116,7 +117,8 @@ func TestComposeConfigs(t *testing.T) {
groupId: 1000, groupId: 1000,
}, configContentSecond) }, configContentSecond)
configContentThird, err := readFileInfoInContainer(t, cli, name, containerName, "/etc/new-dir/config-from-file.conf") configContentThird, err := readFileInfoInContainer(t, cli, name, containerName,
"/etc/new-dir/config-from-file.conf")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, fileInfo{ assert.Equal(t, fileInfo{
permissions: 0o644, permissions: 0o644,
+12 -10
View File
@@ -40,7 +40,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx) plan, err := deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 1, "Expected 1 service to deploy") assert.Len(t, plan.Services, 1, "Expected 1 service to deploy")
err = deployment.Run(ctx) err = deployment.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -93,7 +93,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx) plan, err := deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 3, "Expected 3 services to deploy") assert.Len(t, plan.Services, 3, "Expected 3 services to deploy")
err = deployment.Run(ctx) err = deployment.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -168,7 +168,7 @@ func TestComposeDeployment(t *testing.T) {
redeployPlan, err := redeploy.Plan(ctx) redeployPlan, err := redeploy.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, redeployPlan.Operations, 0, "Expected no operations - deployment should be up to date") assert.True(t, redeployPlan.IsEmpty(), "Expected no operations - deployment should be up to date")
// Deploy with ForceRecreate - should recreate all service containers. // Deploy with ForceRecreate - should recreate all service containers.
strategy := &deploy.RollingStrategy{ForceRecreate: true} strategy := &deploy.RollingStrategy{ForceRecreate: true}
@@ -177,7 +177,7 @@ func TestComposeDeployment(t *testing.T) {
recreatePlan, err := recreateDeploy.Plan(ctx) recreatePlan, err := recreateDeploy.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, recreatePlan.Operations, 3, "Expected 3 services to be recreated") assert.Len(t, recreatePlan.Services, 3, "Expected 3 services to be recreated")
err = recreateDeploy.Run(ctx) err = recreateDeploy.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -242,7 +242,8 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx) plan, err := deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 5, "Expected 2 volumes creation and 3 services to deploy") assert.Len(t, plan.Volumes, 2, "Expected 2 volume creation operations")
assert.Len(t, plan.Services, 3, "Expected 3 services to deploy")
err = deployment.Run(ctx) err = deployment.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -384,7 +385,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err = deployment.Plan(ctx) plan, err = deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 0, "Expected no new operations after deployment") assert.True(t, plan.IsEmpty(), "Expected no new operations after deployment")
}) })
t.Run("x-machines placement constraint", func(t *testing.T) { t.Run("x-machines placement constraint", func(t *testing.T) {
@@ -403,7 +404,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx) plan, err := deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 1, "Expected 1 service to deploy") assert.Len(t, plan.Services, 1, "Expected 1 service to deploy")
err = deployment.Run(ctx) err = deployment.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -456,7 +457,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx) plan, err := deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 1, "Expected 1 service to deploy") assert.Len(t, plan.Services, 1, "Expected 1 service to deploy")
err = deployment.Run(ctx) err = deployment.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -504,7 +505,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx) plan, err := deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 1, "Expected 1 service to deploy") assert.Len(t, plan.Services, 1, "Expected 1 service to deploy")
err = deployment.Run(ctx) err = deployment.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -556,7 +557,8 @@ volumes:
plan, err := deployment.Plan(ctx) plan, err := deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 2, "Expected 1 volume creation and 1 service to deploy") assert.Len(t, plan.Volumes, 1, "Expected 1 volume creation operation")
assert.Len(t, plan.Services, 1, "Expected 1 service to deploy")
}) })
t.Run("global service auto-creates volumes on all machines", func(t *testing.T) { t.Run("global service auto-creates volumes on all machines", func(t *testing.T) {
+1 -1
View File
@@ -1052,7 +1052,7 @@ myapp.example.com {
// Machine 0: vol1, vol2 // Machine 0: vol1, vol2
// Machine 1: vol1, vol2 // Machine 1: vol1, vol2
// Machine 2: vol1 // Machine 2: vol1
for i := 0; i < 3; i++ { for i := range 3 {
_, err := cli.CreateVolume(ctx, c.Machines[i].Name, volume.CreateOptions{Name: vol1Name}) _, err := cli.CreateVolume(ctx, c.Machines[i].Name, volume.CreateOptions{Name: vol1Name})
require.NoError(t, err) require.NoError(t, err)
if i < 2 { if i < 2 {
@@ -322,6 +322,24 @@ Choose unique names to avoid conflicts with services deployed from other Compose
::: :::
## Deploy to a specific cluster context
If you manage multiple clusters, you can set `x-context` in your Compose file to make sure it always deploys to the
correct one. You won't need to remember to manually switch clusters with `uc ctx` or `--context`.
```yaml title="compose.yaml"
x-context: prod
services:
web:
image: myapp:latest
```
With this configuration, `uc deploy` and other commands using the Compose file will always target the `prod` context,
regardless of your currently active context. You can still override it with the `--context` flag if needed.
See [`x-context`](../../8-compose-file-reference/1-support-matrix.md#x-context) for more details.
## Use a different Compose file location ## Use a different Compose file location
If your Compose file has a different name or location, use the `-f/--file` flag to specify its path: If your Compose file has a different name or location, use the `-f/--file` flag to specify its path:
+175
View File
@@ -0,0 +1,175 @@
# CLI configuration file
The [`uc`](./2-getting-started/1-install-cli.md) CLI stores **cluster connection details** in a YAML configuration file.
Every time you run a command like `uc ls` or `uc deploy`, it reads this file to figure out which cluster to connect to
and how to reach its machines over SSH.
You rarely need to edit this file by hand. `uc` creates and updates it automatically when you run commands like
`uc machine init`, `uc machine add`, or `uc ctx`.
## Config location
The default config file path is `~/.config/uncloud/config.yaml`. You can change this with the `--uncloud-config`
global flag or the `UNCLOUD_CONFIG` environment variable:
```shell
# Use a custom config file for this command
uc --uncloud-config ./my-config.yaml ls
# Or set the environment variable to use a custom config file for all commands
export UNCLOUD_CONFIG=~/my-uncloud-config.yaml
uc ls
```
## Config structure
Here is an example config file for a setup with two clusters, `prod` and `dev`:
```yaml title="~/.config/uncloud/config.yaml"
current_context: prod
contexts:
prod:
connections:
- ssh: user@137.123.45.67
- ssh: myserver # host alias from SSH config
dev:
connections:
- ssh: ubuntu@dev.example.com
ssh_key_file: ~/.ssh/id_ed25519
machine_id: 3f60c88ac6b0f250d8aecb92a090922f
```
The config has two top-level attributes: `current_context` and `contexts`.
### `current_context`
The name of the active context. `uc` uses this context by default when you run any command. You can switch to a
different context with an interactive [`uc ctx`](9-cli-reference/uc_ctx.md) command, non-interactively with
[`uc ctx use`](9-cli-reference/uc_ctx_use.md), or the `--context` flag.
### `contexts`
A map of named contexts. Each context represents a cluster and contains a list of machine connections.
A context is not the same thing as a cluster. It is your local view of a cluster: which machines you can connect through
and in what order to try them. Two people on the same team can have different contexts pointing to the same cluster,
each with their own preferred entry point.
You can also create multiple contexts for the same cluster. For example, one that connects through a machine with a
public IP when you're not in the office, and another that connects through a private machine on the office network when
you're on-site to reduce latency.
### `connections`
Each connection in the list represents a machine in the cluster. `uc` only needs to reach one machine to work with the
entire cluster. That machine acts as an **entry point** and forwards requests to other machines as needed.
The first connection in the list is the default one that `uc` tries first. If it's unavailable, `uc` moves on to the
next one. You can change the default connection with an interactive command
[`uc ctx conn`](9-cli-reference/uc_ctx_connection.md).
Every connection must have exactly one connection type attribute:
| Attribute | Format | Description |
|-----------|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| `ssh` | `user@host[:port]` | Connect using the system `ssh` command with full SSH config support (default for new connections added with `uc machine init/add`) |
| `ssh_go` | `user@host[:port]` | Connect using the Go's built-in SSH library (no SSH config support) |
| `tcp` | `host:port` | Connect directly to the machine gRPC API over TCP (for advanced users with custom setups) |
| `unix` | `/run/uncloud/uncloud.sock` | Connect directly to the machine gRPC API over a Unix socket (for running `uc` locally on the cluster machines) |
A connection can also have these optional attributes:
| Attribute | Description |
|----------------|-------------------------------------------------------------------------------------------------------------------------|
| `ssh_key_file` | Path to the SSH private key for this machine (maps to `-i` in the `ssh` command) |
| `machine_id` | Unique identifier of the machine (used to match and remove the connection when removing a machine with `uc machine rm`) |
## How the config gets created
You don't need to create this file yourself. `uc` manages it for you:
1. **`uc machine init`** creates a new context and adds the first machine connection. If you don't specify a context
name with `-c`, it uses `default`. If `default` already exists, it auto-increments to `default-1`, `default-2`, and
so on.
2. **`uc machine add`** appends a new machine connection to the current context.
For example, after initialising a cluster:
```shell
uc machine init root@203.0.113.1 -c prod
```
`uc` creates a config file that looks like this:
```yaml title="~/.config/uncloud/config.yaml"
current_context: prod
contexts:
prod:
connections:
- ssh: root@203.0.113.1
ssh_key_file: ~/.ssh/id_ed25519
machine_id: a1b2c3d4e5f6
```
## Connection resolution
When you run a command, `uc` determines which cluster to connect to using this priority:
1. If `--connect` is set, `uc` connects directly to that machine and ignores the config file entirely.
2. If `--context` is set, `uc` uses that context from the config.
3. Otherwise, `uc` uses `current_context` from the config.
Once the context is resolved, `uc` tries each connection in the context's list in order until one succeeds.
## Global flags and environment variables
These flags are available on every `uc` command. Each flag can also be set with an environment variable. The flag takes
priority if both are set.
| Flag | Environment variable | Description |
|--------------------|----------------------|---------------------------------------------------|
| `--uncloud-config` | `UNCLOUD_CONFIG` | Path to the config file |
| `--context` | `UNCLOUD_CONTEXT` | Use a specific context instead of the current one |
| `--connect` | `UNCLOUD_CONNECT` | Bypass the config file and connect directly |
### The `--connect` flag
The `--connect` flag lets you run one-off commands against a machine without setting up a config file. It accepts these
formats:
```shell
# System 'ssh' command (default)
uc --connect root@203.0.113.1 ls
# System 'ssh' command (explicit scheme)
uc --connect ssh://root@203.0.113.1 ls
# Go's built-in SSH (no SSH config support)
uc --connect ssh+go://root@203.0.113.1 ls
# Direct TCP
uc --connect tcp://10.210.0.1:1234 ls
# Unix socket
uc --connect unix:///run/uncloud/uncloud.sock ls
```
:::warning
You can't use `--connect` with `uc machine init`. The `init` command needs the config file to save the new cluster
context.
:::
## Managing contexts
Use these commands to work with contexts in your config:
- [`uc ctx`](9-cli-reference/uc_ctx.md): Switch contexts using an interactive TUI
- [`uc ctx ls`](9-cli-reference/uc_ctx_ls.md): List all contexts and see which one is current
- [`uc ctx use`](9-cli-reference/uc_ctx_use.md): Switch the current context
- [`uc ctx conn`](9-cli-reference/uc_ctx_connection.md): Change the default connection for the current context
You can also set `x-context` in your Compose file to pin a specific context for deployments. See
[Deploy to a specific cluster context](4-guides/1-deployments/1-deploy-app.md#deploy-to-a-specific-cluster-context)
for details.
@@ -1,10 +1,17 @@
# Compose support matrix # Compose support matrix
Uncloud supports a subset of the [Compose specification](https://compose-spec.io/) with some extensions and limitations. Uncloud supports a subset of the [Compose specification](https://compose-spec.io/) with some extensions and limitations.
The following table shows the support status for main Compose features: The following table shows the support status for main Compose features.
:::info
If you rely on a specific Compose feature that is not supported by Uncloud, please submit a feature request in
[GitHub Discussions](https://github.com/psviderski/uncloud/discussions) or on [Discord](https://uncloud.run/discord).
:::
| Feature | Support Status | Notes | | Feature | Support Status | Notes |
|---------------------|--------------------|----------------------------------------------------------------------------------------------------------------| |----------------------------------|--------------------|----------------------------------------------------------------------------------------------------------------|
| **Services** | | | | **Services** | | |
| `build` | ✅ Supported | Build context and Dockerfile | | `build` | ✅ Supported | Build context and Dockerfile |
| `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) | | `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) |
@@ -31,14 +38,17 @@ The following table shows the support status for main Compose features:
| `mem_swappiness` | ❌ Not supported | | | `mem_swappiness` | ❌ Not supported | |
| `memswap_limit` | ❌ Not supported | | | `memswap_limit` | ❌ Not supported | |
| `networks` | ❌ Not supported | All containers share cluster network | | `networks` | ❌ Not supported | All containers share cluster network |
| `pid` | ✅ Supported | Set the PID namespace mode, `pid: host` only |
| `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](#x-ports) for HTTP/HTTPS | | `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](#x-ports) for HTTP/HTTPS |
| `privileged` | ✅ Supported | Run containers in privileged mode | | `privileged` | ✅ Supported | Run containers in privileged mode |
| `pull_policy` | ✅ Supported | `always`, `missing`, `never` | | `pull_policy` | ✅ Supported | `always`, `missing`, `never` |
| `secrets` | ❌ Not supported | Use configs or environment variables | | `secrets` | ❌ Not supported | Use configs or environment variables |
| `security_opt` | ❌ Not supported | | | `security_opt` | ❌ Not supported | |
| `shm_size` | ✅ Supported | Shared memory size |
| `stop_grace_period` | ✅ Supported | Time to wait after SIGTERM before SIGKILL | | `stop_grace_period` | ✅ Supported | Time to wait after SIGTERM before SIGKILL |
| `storage_opt` | ❌ Not supported | | | `storage_opt` | ❌ Not supported | |
| `sysctls` | ✅ Supported | Namespaced kernel parameters | | `sysctls` | ✅ Supported | Namespaced kernel parameters |
| `ulimits` | ✅ Supported | Resource limits |
| `user` | ✅ Supported | Set container user | | `user` | ✅ Supported | Set container user |
| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs | | `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs |
| **Deploy** | | | | **Deploy** | | |
@@ -56,17 +66,24 @@ The following table shows the support status for main Compose features:
| Tmpfs mounts | ✅ Supported | In-memory filesystems | | Tmpfs mounts | ✅ Supported | In-memory filesystems |
| Volume labels | ✅ Supported | Custom labels | | Volume labels | ✅ Supported | Custom labels |
| External volumes | ✅ Supported | Must exist before deployment | | External volumes | ✅ Supported | Must exist before deployment |
| Volume drivers | ⚠️ Limited | Local driver only | | [Volume drivers][volume-drivers] | ✅ Supported | `local` (supports [NFS][volume-nfs], [CIFS/Samba][volume-cifs]) and manually installed third-party drivers |
| **Configs** | | | | **Configs** | | |
| File-based configs | ✅ Supported | Read from file | | File-based configs | ✅ Supported | Read from file |
| Inline configs | ✅ Supported | Defined in compose file | | Inline configs | ✅ Supported | Defined in compose file |
| External configs | ❌ Not supported | Not supported | | External configs | ❌ Not supported | Not supported |
| Short syntax | ❌ Not supported | Use long syntax only | | Short syntax | ❌ Not supported | Use long syntax only |
| **Extensions** | | | | **Extensions** | | |
| `x-context` | ✅ Uncloud-specific | Cluster context override |
| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration | | `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration |
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints | | `x-machines` | ✅ Uncloud-specific | Machine placement constraints |
| `x-ports` | ✅ Uncloud-specific | Service port publishing | | `x-ports` | ✅ Uncloud-specific | Service port publishing |
[volume-drivers]: https://docs.docker.com/engine/storage/volumes/#use-a-volume-driver
[volume-nfs]: https://docs.docker.com/engine/storage/volumes/#create-a-service-which-creates-an-nfs-volume
[volume-cifs]: https://docs.docker.com/engine/storage/volumes/#create-cifssamba-volumes
### Legend ### Legend
-**Supported**: Feature works as documented -**Supported**: Feature works as documented
@@ -77,6 +94,32 @@ The following table shows the support status for main Compose features:
Uncloud provides several custom extensions to enhance the Compose experience: Uncloud provides several custom extensions to enhance the Compose experience:
### `x-context`
Set the cluster context for all commands that use the Compose file, such as `deploy`, `build`, and `logs`. This is
useful when you manage multiple clusters and want to make sure a Compose file is always deployed to the right one. No
need to remember to manually switch clusters with `uc ctx` or `--context`.
`x-context` is a top-level key, not a service-level attribute.
```yaml
x-context: prod
services:
web:
image: nginx
```
The `--context` and `--connect` flags take precedence over `x-context`. If you don't specify any of these, the current
context from your Uncloud config (`--uncloud-config`) is used.
:::warning
If you share the Compose file with other users, make sure to use the same context name for the target cluster in your
Uncloud configs.
:::
### `x-ports` ### `x-ports`
Expose HTTP/HTTPS service ports via the Caddy reverse proxy, or bind TCP/UDP ports directly to the host: Expose HTTP/HTTPS service ports via the Caddy reverse proxy, or bind TCP/UDP ports directly to the host:
+10 -1
View File
@@ -16,7 +16,8 @@ uc run IMAGE [COMMAND...] [flags]
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.
-h, --help help for run -h, --help help for run
-m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine) -m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine)
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified. --memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).
Default unit is bytes if no suffix specified.
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte) Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
--mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated") --mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated")
-n, --name string Assign a name to the service. A random name is generated if not specified. -n, --name string Assign a name to the service. A random name is generated if not specified.
@@ -31,6 +32,14 @@ uc run IMAGE [COMMAND...] [flags]
-p 53:5353/udp@host Bind UDP port 5353 to host port 53 -p 53:5353/udp@host Bind UDP port 5353 to host port 53
--pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing") --pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing")
--replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1) --replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1)
--shm-size bytes Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer
with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
--ulimit strings Set resource limits for service containers. Can be specified multiple times.
Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.
Examples:
--ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files
--ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes
-u, --user string User name or UID and optionally group name or GID used for running the command inside service containers. -u, --user string User name or UID and optionally group name or GID used for running the command inside service containers.
Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image. Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where -v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
+3 -1
View File
@@ -4,7 +4,7 @@ Scale a replicated service by changing the number of replicas.
## Synopsis ## Synopsis
Scale a replicated service by changing the number of replicas. Scaling down requires confirmation. Scale a replicated service by changing the number of replicas.
``` ```
uc scale SERVICE REPLICAS [flags] uc scale SERVICE REPLICAS [flags]
@@ -14,6 +14,8 @@ uc scale SERVICE REPLICAS [flags]
``` ```
-h, --help help for scale -h, --help help for scale
-y, --yes Auto-confirm scaling plan. Should be explicitly set when running non-interactively,
e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]
``` ```
## Options inherited from parent commands ## Options inherited from parent commands
+10 -1
View File
@@ -16,7 +16,8 @@ uc service run IMAGE [COMMAND...] [flags]
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.
-h, --help help for run -h, --help help for run
-m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine) -m, --machine strings Placement constraint by machine names, limiting which machines the service can run on. Can be specified multiple times or as a comma-separated list of machine names. (default is any suitable machine)
--memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified. --memory bytes Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).
Default unit is bytes if no suffix specified.
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte) Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
--mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated") --mode string Replication mode of the service: either 'replicated' (a specified number of containers across the machines) or 'global' (one container on every machine). (default "replicated")
-n, --name string Assign a name to the service. A random name is generated if not specified. -n, --name string Assign a name to the service. A random name is generated if not specified.
@@ -31,6 +32,14 @@ uc service run IMAGE [COMMAND...] [flags]
-p 53:5353/udp@host Bind UDP port 5353 to host port 53 -p 53:5353/udp@host Bind UDP port 5353 to host port 53
--pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing") --pull string Pull image from the registry before running service containers ('always', 'missing', 'never'). (default "missing")
--replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1) --replicas uint Number of containers to run for the service. Only valid for a replicated service. (default 1)
--shm-size bytes Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer
with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.
Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)
--ulimit strings Set resource limits for service containers. Can be specified multiple times.
Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.
Examples:
--ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files
--ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes
-u, --user string User name or UID and optionally group name or GID used for running the command inside service containers. -u, --user string User name or UID and optionally group name or GID used for running the command inside service containers.
Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image. Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where -v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
@@ -4,7 +4,7 @@ Scale a replicated service by changing the number of replicas.
## Synopsis ## Synopsis
Scale a replicated service by changing the number of replicas. Scaling down requires confirmation. Scale a replicated service by changing the number of replicas.
``` ```
uc service scale SERVICE REPLICAS [flags] uc service scale SERVICE REPLICAS [flags]
@@ -14,6 +14,8 @@ uc service scale SERVICE REPLICAS [flags]
``` ```
-h, --help help for scale -h, --help help for scale
-y, --yes Auto-confirm scaling plan. Should be explicitly set when running non-interactively,
e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]
``` ```
## Options inherited from parent commands ## Options inherited from parent commands