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/**"
permissions:
contents: read
env:
MISE_VERSION: "2026.3.7"
jobs:
test:
runs-on: ubuntu-latest
@@ -25,10 +27,12 @@ jobs:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- name: Install Mise
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
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
run: |
@@ -44,7 +48,7 @@ jobs:
- name: Run tests
run: |
make ucind-image
mise ucind:image
make test
timeout-minutes: 15
@@ -61,15 +65,18 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Mise
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with:
version: "2025.9.6"
env:
GITHUB_TOKEN: ${{ github.token }}
version: ${{ env.MISE_VERSION }}
# 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
run: |
make proto
# check if the generated files are up to date
mise run proto
# Check if the generated files are up to date.
git diff --exit-code ||
(echo "Generated protobuf files are not up to date. Please run 'make proto' and commit the changes." && exit 1)
(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.*"
permissions:
contents: read
env:
MISE_VERSION: "2026.3.7"
jobs:
lint:
runs-on: ubuntu-latest
@@ -20,20 +22,14 @@ jobs:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- name: Install Mise
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
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
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
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)
- name: Lint
run: make lint
timeout-minutes: 10
+2
View File
@@ -8,7 +8,9 @@
*.so
*.dylib
/uncloud
/uncloud-*
/uncloudd
/uncloudd-*
# OS X
.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 TARGETARCH
@@ -39,10 +39,11 @@ CMD ["corrosion", "agent"]
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
ARG TARGETOS
ARG TARGETARCH
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.
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
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
ucind-cluster:
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
corrosion-image:
docker build -t "$(CORROSION_IMAGE)" --target corrosion .
@@ -58,10 +15,6 @@ corrosion-image:
corrosion-multiarch-image-push:
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
ucind-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
@@ -84,12 +37,12 @@ test-e2e:
.PHONY: test-clean
test-clean:
@CONTAINERS=$$(docker ps --filter "name=ucind-test" -q); \
@CONTAINERS=$$(docker ps --filter "label=ucind.managed" -q); \
if [ -n "$$CONTAINERS" ]; then \
echo "Killing containers..."; \
docker kill $$CONTAINERS; \
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 \
echo "Removing stopped containers..."; \
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)
}
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps)
if err != nil {
return fmt.Errorf("determine services to build: %w", err)
+47 -15
View File
@@ -9,9 +9,11 @@ import (
"slices"
"strings"
"charm.land/lipgloss/v2"
"github.com/docker/cli/cli/streams"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
@@ -70,9 +72,11 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
if !errors.Is(err, api.ErrNotFound) {
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 {
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.
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))
if len(currentImages) > 1 {
commaSeparatedImages := strings.Join(currentImages, ", ")
fmt.Printf("Current images (multiple versions detected): %s\n", commaSeparatedImages)
formattedImages := make([]string, len(currentImages))
for i, img := range currentImages {
formattedImages[i] = tui.FormatImage(img, lipgloss.NewStyle())
}
fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") +
strings.Join(formattedImages, tui.Faint.Render(", ")))
} else {
fmt.Printf("Current image: %s\n", currentImages[0])
fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], lipgloss.NewStyle()))
}
}
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()
@@ -105,7 +113,8 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
}
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)
@@ -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.
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
if err != nil {
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
fmt.Println()
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
fmt.Println()
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
deployTarget := ""
if directConn != "" {
deployTarget = directConn
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
fmt.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
deployTarget = contextName
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
fmt.Println()
}
fmt.Println()
fmt.Println("Deployment plan:")
fmt.Println(plan.Format(resolver))
fmt.Println(plan.Format())
summary := plan.FormatSummary()
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
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 {
return fmt.Errorf("confirm deployment: %w", err)
}
+1 -1
View File
@@ -3,7 +3,7 @@ package context
import (
"fmt"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/spf13/cobra"
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"maps"
"slices"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/spf13/cobra"
)
+39 -45
View File
@@ -4,17 +4,16 @@ import (
"context"
"errors"
"fmt"
"strings"
"os"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/compose/v2/pkg/progress"
"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/compose"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"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)
}
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
if len(opts.services) > 0 {
// Includes service dependencies by default. This is the default docker compose behavior.
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)
}
if len(plan.Operations) == 0 {
if plan.IsEmpty() {
fmt.Println("Services are up to date.")
return nil
}
fmt.Println(lipgloss.NewStyle().Bold(true).Render("Deployment plan"))
if err = printPlan(ctx, clusterClient, plan); err != nil {
return fmt.Errorf("print deployment plan: %w", err)
}
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
fmt.Println()
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
deployTarget := ""
if directConn != "" {
deployTarget = directConn
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
fmt.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
deployTarget = contextName
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
fmt.Println()
}
fmt.Println(plan.Format())
// Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
if !opts.yes {
if !cli.IsStdinTerminal() {
if !tui.IsStdinTerminal() {
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")
}
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 {
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 {
if err := plan.Execute(ctx, clusterClient); err != nil {
return fmt.Errorf("deploy services: %w", err)
}
return nil
}, uncli.ProgressOut(), "Deploying services")
}
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")
}, uncli.ProgressOut(), title)
}
+6 -4
View File
@@ -3,17 +3,19 @@ package image
import (
"context"
"fmt"
"os"
"slices"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/charmbracelet/colorprofile"
"github.com/containerd/platforms"
"github.com/docker/docker/api/types/image"
"github.com/docker/go-units"
"github.com/muesli/termenv"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
@@ -222,7 +224,7 @@ func formatPlatforms(platforms []string) string {
Foreground(lipgloss.Color("0")).
Background(lipgloss.Color("152"))
// 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)
}
+18 -13
View File
@@ -8,12 +8,13 @@ import (
"strings"
"time"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/internal/cli"
"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/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
@@ -154,8 +155,12 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
err = spinner.New().
Title(" Waiting for the machine to join the cluster...").
Type(spinner.MiniDot).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
TitleStyle(lipgloss.NewStyle()).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
}).
@@ -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{})
if err != nil {
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)
}
fmt.Println()
if len(plan.Operations) == 0 {
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
} else {
// Initialise a machine and container name resolver to properly format the plan output.
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, caddySvc)
if err != nil {
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
}
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
fmt.Println()
fmt.Print(plan.Format())
fmt.Println("caddy 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()
if !opts.yes {
confirmed, err := cli.Confirm()
confirmed, err := tui.Confirm("Proceed with deployment?")
if err != nil {
return fmt.Errorf("confirm deployment: %w", err)
}
+12 -5
View File
@@ -7,8 +7,8 @@ import (
"strings"
"time"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uncloud/dns"
@@ -150,7 +150,10 @@ Connection methods:
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
if uncli.Config == nil {
// 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)
@@ -202,8 +205,12 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
err = spinner.New().
Title(" Waiting for the cluster to be ready...").
Type(spinner.MiniDot).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
TitleStyle(lipgloss.NewStyle()).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
return client.WaitClusterReady(ctx, 1*time.Minute)
}).
+5 -4
View File
@@ -9,11 +9,12 @@ import (
"strings"
"sync"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/tree"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/tree"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
@@ -118,7 +119,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
}
if !opts.yes {
confirmed, err := cli.Confirm()
confirmed, err := tui.Confirm("")
if err != nil {
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.
if uncli.Config != nil {
contextName := uncli.GetContextOverrideOrCurrent()
contextName := uncli.ContextOverrideOrCurrent()
if context, ok := uncli.Config.Contexts[contextName]; ok {
for i, c := range context.Connections {
if c.MachineID == m.Id {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/netip"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
"github.com/psviderski/uncloud/cmd/uncloud/dns"
+11 -5
View File
@@ -6,11 +6,12 @@ import (
"sort"
"time"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra"
"github.com/psviderski/uncloud/internal/cli"
@@ -85,7 +86,12 @@ func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
err = spinner.New().
Title(" Collecting container info...").
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 {
containers, err = collectContainers(ctx, clusterClient)
return err
@@ -226,7 +232,7 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
}
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))
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) {
// Setup container data
containerData := map[string]interface{}{
containerData := map[string]any{
"Id": "container1",
"Name": "test-container",
"Config": map[string]interface{}{
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]interface{}{
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON, err := json.Marshal(containerData)
require.NoError(t, err)
serviceSpecJSON, err := json.Marshal(map[string]interface{}{})
serviceSpecJSON, err := json.Marshal(map[string]any{})
require.NoError(t, err)
// 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
// Setup container data
containerData1 := map[string]interface{}{
containerData1 := map[string]any{
"Id": "container1",
}
containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]interface{}{
containerData2 := map[string]any{
"Id": "container2",
"Config": map[string]interface{}{
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]interface{}{
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
serviceSpecJSON, _ := json.Marshal(map[string]any{})
// Setup mocks
mockDocker := &mockDockerClient{
@@ -210,41 +210,41 @@ func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
// Verify correct mapping of containers to machines when metadata is present
// Setup container data
containerData1 := map[string]interface{}{
containerData1 := map[string]any{
"Id": "container1",
"Name": "container-1",
"Config": map[string]interface{}{
"Config": map[string]any{
"Image": "image-1",
},
"State": map[string]interface{}{
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]interface{}{
containerData2 := map[string]any{
"Id": "container2",
"Name": "container-2",
"Config": map[string]interface{}{
"Config": map[string]any{
"Image": "image-2",
},
"State": map[string]interface{}{
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
serviceSpecJSON, _ := json.Marshal(map[string]any{})
// Setup mocks
mockDocker := &mockDockerClient{
@@ -321,22 +321,22 @@ func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
// Case: 1 msc with nil metadata but no machines at all
containerData := map[string]interface{}{
containerData := map[string]any{
"Id": "container1",
"Config": map[string]interface{}{
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]interface{}{
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
@@ -378,22 +378,22 @@ func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) {
// Case: msc with metadata that is not in the IP-to-name mapping
containerData := map[string]interface{}{
containerData := map[string]any{
"Id": "container1",
"Config": map[string]interface{}{
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]interface{}{
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/docker/cli/cli/streams"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"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 {
// Disable TTY allocation if not connected to a terminal
if !cli.IsStdoutTerminal() {
if !tui.IsStdoutTerminal() {
opts.noTty = true
}
+18 -13
View File
@@ -4,16 +4,18 @@ import (
"context"
"errors"
"fmt"
"image/color"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
mapset "github.com/deckarep/golang-set/v2"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose"
@@ -106,6 +108,9 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
if err != nil {
return fmt.Errorf("load Compose file(s): %w", err)
}
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
// View logs for all services, including disabled by inactive profiles.
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...)
if len(serviceNames) == 0 {
@@ -167,7 +172,7 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
serviceNames = foundServices
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.
var colorPalette = []lipgloss.Color{
lipgloss.Color("10"), // Bright green
lipgloss.Color("11"), // Bright yellow
lipgloss.Color("12"), // Bright blue
lipgloss.Color("13"), // Bright magenta
lipgloss.Color("14"), // Bright cyan
lipgloss.Color("2"), // Green
lipgloss.Color("3"), // Yellow
lipgloss.Color("4"), // Blue
lipgloss.Color("5"), // Magenta
lipgloss.Color("6"), // Cyan
var colorPalette = []color.Color{
lipgloss.BrightGreen,
lipgloss.BrightYellow,
lipgloss.BrightBlue,
lipgloss.BrightMagenta,
lipgloss.BrightCyan,
lipgloss.Green,
lipgloss.Yellow,
lipgloss.Blue,
lipgloss.Magenta,
lipgloss.Cyan,
}
// logFormatter handles formatting and printing of log entries with dynamic column alignment.
+47 -9
View File
@@ -9,6 +9,7 @@ import (
dockeropts "github.com/docker/cli/opts"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/daemon/names"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
@@ -27,11 +28,13 @@ type runOptions struct {
machines []string
memory dockeropts.MemBytes
mode string
shmSize dockeropts.MemBytes
name string
privileged bool
publish []string
pull string
replicas uint
ulimits []string
user 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 "+
"multiple times or as a comma-separated list of machine names. (default is any suitable machine)")
cmd.Flags().VarP(&opts.memory, "memory", "",
"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.\n"+
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
"Default unit is bytes if no suffix specified.\n"+
"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)")
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
"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", "",
"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.")
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,
"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"+
@@ -194,13 +207,18 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
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 {
return spec, err
}
placement := api.Placement{
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
volumes, mounts, err := parseVolumeFlags(opts.volumes)
if err != nil {
return spec, err
}
spec = api.ServiceSpec{
@@ -211,8 +229,10 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
Privileged: opts.privileged,
PullPolicy: opts.pull,
Resources: api.ContainerResources{
CPU: opts.cpu.Value(),
Memory: opts.memory.Value(),
CPU: opts.cpu.Value(),
Memory: opts.memory.Value(),
SharedMemory: opts.shmSize.Value(),
Ulimits: ulimits,
},
User: opts.user,
VolumeMounts: mounts,
@@ -302,6 +322,24 @@ func parseVolumeFlags(volumes []string) ([]api.VolumeSpec, []api.VolumeMount, er
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) {
var spec api.VolumeSpec
var mount api.VolumeMount
@@ -320,8 +358,8 @@ func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error
volumeNoCopy := false
if len(parts) == 3 {
opts := strings.Split(parts[2], ",")
for _, opt := range opts {
opts := strings.SplitSeq(parts[2], ",")
for opt := range opts {
switch opt {
case "ro", "readonly":
mount.ReadOnly = true
+56 -35
View File
@@ -2,12 +2,16 @@ package service
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
"github.com/charmbracelet/huh"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
@@ -15,6 +19,7 @@ import (
type scaleOptions struct {
service string
replicas uint
yes bool
}
func NewScaleCommand(groupID string) *cobra.Command {
@@ -22,9 +27,11 @@ func NewScaleCommand(groupID string) *cobra.Command {
cmd := &cobra.Command{
Use: "scale SERVICE 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),
RunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0]
@@ -39,6 +46,10 @@ func NewScaleCommand(groupID string) *cobra.Command {
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
}
@@ -71,7 +82,8 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
currentReplicas := uint(len(svc.Containers))
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
}
@@ -86,23 +98,51 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
}
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
}
if opts.replicas < currentReplicas {
// 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.Println(tui.Bold.Underline(true).Render("Scaling plan"))
fmt.Println()
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
deployTarget := ""
if directConn != "" {
deployTarget = directConn
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
fmt.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
deployTarget = contextName
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
fmt.Println()
}
fmt.Println(plan.Format())
summary := plan.FormatSummary()
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
fmt.Println()
// 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")
}
fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas)
fmt.Println(plan.Format(resolver))
fmt.Println()
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("?")
}
// Ask for confirmation before scaling down as it may cause data loss.
confirmed, err := confirm()
confirmed, err := tui.Confirm(title)
if err != nil {
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 {
if _, err = deployment.Run(ctx); err != nil {
return fmt.Errorf("deploy service: %w", err)
@@ -125,23 +166,3 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
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"
"strings"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/machine/api/pb"
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
@@ -79,7 +80,7 @@ func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOpti
}
fmt.Println()
confirmed, err := cli.Confirm()
confirmed, err := tui.Confirm("")
if err != nil {
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}
}
func (l *ipfsLogger) Debug(args ...interface{}) {
func (l *ipfsLogger) Debug(args ...any) {
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...))
}
func (l *ipfsLogger) Error(args ...interface{}) {
func (l *ipfsLogger) Error(args ...any) {
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...))
}
func (l *ipfsLogger) Fatal(args ...interface{}) {
func (l *ipfsLogger) Fatal(args ...any) {
l.log.Error(fmt.Sprint(args...))
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...))
os.Exit(1)
}
func (l *ipfsLogger) Info(args ...interface{}) {
func (l *ipfsLogger) Info(args ...any) {
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...))
}
func (l *ipfsLogger) Panic(args ...interface{}) {
func (l *ipfsLogger) Panic(args ...any) {
msg := fmt.Sprint(args...)
l.log.Error(msg)
panic(msg)
}
func (l *ipfsLogger) Panicf(format string, args ...interface{}) {
func (l *ipfsLogger) Panicf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
l.log.Error(msg)
panic(msg)
}
func (l *ipfsLogger) Warn(args ...interface{}) {
func (l *ipfsLogger) Warn(args ...any) {
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...))
}
+19 -20
View File
@@ -1,19 +1,19 @@
module github.com/psviderski/uncloud
go 1.25
go 1.26
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/Masterminds/semver v1.5.0
github.com/Masterminds/squirrel v1.5.4
github.com/alecthomas/chroma/v2 v2.20.0
github.com/caddyserver/caddy/v2 v2.8.4
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v0.21.0
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/charmbracelet/colorprofile v0.4.2
github.com/compose-spec/compose-go/v2 v2.9.0
github.com/containerd/errdefs v1.0.0
github.com/containerd/platforms v1.0.0-rc.1
@@ -43,7 +43,6 @@ require (
github.com/miekg/dns v1.1.65
github.com/mitchellh/mapstructure v1.5.0
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/image-spec v1.1.1
github.com/psviderski/unregistry v0.4.1
@@ -57,8 +56,8 @@ require (
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0
golang.org/x/sync v0.17.0
golang.org/x/sys v0.36.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.42.0
golang.org/x/term v0.34.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
@@ -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/sts v1.30.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/bgentry/speakeasy v0.2.0 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/caddyserver/certmagic v0.21.4 // 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/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
github.com/charmbracelet/x/term v0.2.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/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/console v1.0.5 // 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/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // 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/felixge/httpsnoop v1.0.4 // 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-pubsub v0.11.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/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
@@ -278,7 +278,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.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/multiformats/go-base32 v0.1.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.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
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/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
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-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
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/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
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/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/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
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.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
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/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
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.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
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/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b h1:oefmPctgff7OB4cDz3Ndp+ewwrh7i+fwth70qvZCieI=
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
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/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
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/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
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.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
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/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
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/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
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.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
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/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
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/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw=
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.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
@@ -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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
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-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
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-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@@ -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.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
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/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/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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.7.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.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -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-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-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-20220412211240-33da011f77ad/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.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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
composetypes "github.com/compose-spec/compose-go/v2/types"
mapset "github.com/deckarep/golang-set/v2"
"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()
}
func (cli *CLI) GetContextOverrideOrCurrent() string {
contextName := cli.contextOverride
if contextName == "" {
contextName = cli.Config.CurrentContext
func (cli *CLI) ContextOverrideOrCurrent() string {
if cli.contextOverride != "" {
return cli.contextOverride
}
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.
@@ -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.
// Both client should be closed after use by the caller.
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) {
contextName := cli.GetContextOverrideOrCurrent()
contextName := cli.ContextOverrideOrCurrent()
c, err := cli.ConnectCluster(ctx)
if err != nil {
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
@@ -561,6 +571,15 @@ func provisionOrConnectRemoteMachine(
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.
func (cli *CLI) ProgressOut() *streams.Out {
return streams.NewOut(os.Stdout)
+12 -11
View File
@@ -7,10 +7,11 @@ import (
"os"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/fs"
"github.com/psviderski/uncloud/pkg/client"
"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.
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
// 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())
cli, err := connectCluster(ctx, conn)
if err != nil {
@@ -125,7 +126,7 @@ type showSpinnerMsg struct{}
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
s := spinner.New()
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{
ctx: ctx,
@@ -187,8 +188,8 @@ func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
m.result.err = fmt.Errorf("connection cancelled")
m.done = true
return m, tea.Quit
@@ -198,15 +199,15 @@ func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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.
if m.done || !m.showSpinner {
return ""
return tea.NewView("")
}
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
return fmt.Sprintf("%s %s\n",
return tea.NewView(fmt.Sprintf("%s %s\n",
m.spinner.View(),
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
for _, value := range values {
for _, v := range strings.Split(value, ",") {
for v := range strings.SplitSeq(value, ",") {
if v = strings.TrimSpace(v); v != "" {
expanded = append(expanded, v)
}
+3 -2
View File
@@ -8,8 +8,9 @@ import (
"strings"
"time"
"charm.land/huh/v2"
"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/sshexec"
"google.golang.org/protobuf/types/known/emptypb"
@@ -88,7 +89,7 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string
}
func promptResetMachine() error {
if !IsStdinTerminal() {
if !tui.IsStdinTerminal() {
return errors.New("the remote machine is already initialised as a cluster member; " +
"cannot ask to confirm reset in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
-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 {
Status int `json:"status,omitempty"`
Message string `json:"msg,omitempty"`
Data authErrorData `json:"data,omitempty"`
Data authErrorData `json:"data"`
}
type authErrorData struct {
+5 -3
View File
@@ -36,10 +36,12 @@ func InspectGitState(dir string) (GitState, error) {
return state, nil
}
// Get the current commit SHA.
sha, err := gitCommand(dir, "rev-parse", "HEAD")
// Get the current commit SHA. An initialised but empty repo has no HEAD yet,
// 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 {
return state, fmt.Errorf("get current commit SHA: %w", err)
state.IsRepo = false
return state, nil
}
state.SHA = strings.TrimSpace(sha)
+16
View File
@@ -46,6 +46,22 @@ func TestInspectGitState_NotARepo(t *testing.T) {
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) {
// Create a temporary git repo.
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.
func (d *Director) FlushRemoteBackends() {
d.remoteBackends.Range(func(key, value interface{}) bool {
d.remoteBackends.Range(func(key, value any) bool {
backend, ok := value.(*RemoteBackend)
if !ok {
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.
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 {
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
+2
View File
@@ -627,6 +627,7 @@ func (s *Server) CreateServiceContainer(
Binds: spec.Container.Volumes,
Init: spec.Container.Init,
Mounts: mounts,
PidMode: container.PidMode(spec.Container.PidMode),
PortBindings: portBindings,
Privileged: spec.Container.Privileged,
Resources: container.Resources{
@@ -642,6 +643,7 @@ func (s *Server) CreateServiceContainer(
RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyUnlessStopped,
},
ShmSize: spec.Container.Resources.SharedMemory,
Sysctls: spec.Container.Sysctls,
}
+1 -1
View File
@@ -103,7 +103,7 @@ func createIptablesChains() error {
}
firstRejectRuleNum := 0
for _, line := range strings.Split(string(out), "\n") {
for line := range strings.SplitSeq(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
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.
for i := 0; i < 2; i++ {
for range 2 {
select {
case <-ctx.Done():
// Close connections to abort ongoing copies.
+32 -23
View File
@@ -1,12 +1,15 @@
package sshexec
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
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
// SSHCLI Connector.
func (r *SSHCLIRemote) buildSSHArgs() []string {
args := []string{"-o", "ConnectTimeout=5"}
// newSSHCommand creates an exec.Cmd for ssh that sends SIGINT on context cancellation, giving the
// remote process a chance to exit gracefully before being killed.
func (r *SSHCLIRemote) newSSHCommand(ctx context.Context, cmd string) *exec.Cmd {
args := []string{
"-o", "ConnectTimeout=5",
// Disable pseudo-terminal allocation to prevent SSH from executing as a login shell.
"-T",
}
if r.port != 0 {
args = append(args, "-p", strconv.Itoa(r.port))
}
if r.keyPath != "" {
args = append(args, "-i", r.keyPath)
}
@@ -42,36 +48,39 @@ func (r *SSHCLIRemote) buildSSHArgs() []string {
if r.user != "" {
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) {
args := r.buildSSHArgs()
args = append(args, cmd)
execCmd := exec.CommandContext(ctx, "ssh", args...)
output, err := execCmd.CombinedOutput()
var stdout, stderr bytes.Buffer
err := r.Stream(ctx, cmd, &stdout, &stderr)
out := strings.TrimSpace(stdout.String())
if err != nil {
return strings.TrimSpace(string(output)),
fmt.Errorf("run command on remote host: %w: %s", err, string(output))
return out, fmt.Errorf("%w: %s", err, stderr.String())
}
return strings.TrimSpace(string(output)), nil
return out, nil
}
func (r *SSHCLIRemote) Stream(ctx context.Context, cmd string, stdout, stderr io.Writer) error {
args := r.buildSSHArgs()
args = append(args, cmd)
sshCmd := r.newSSHCommand(ctx, cmd)
sshCmd.Stdout = stdout
sshCmd.Stderr = stderr
execCmd := exec.CommandContext(ctx, "ssh", args...)
execCmd.Stdout = stdout
execCmd.Stderr = stderr
return execCmd.Run()
if err := sshCmd.Run(); err != nil {
return fmt.Errorf("run command on remote host: %w", err)
}
return nil
}
// no-op as there is no persistent connection.
// Close is no-op as there is no persistent connection.
func (r *SSHCLIRemote) Close() error {
return nil
}
+67 -39
View File
@@ -1,12 +1,13 @@
package sshexec
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
func TestSSHCLIRemote_newSSHCommand(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -15,59 +16,85 @@ func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
host string
port int
keyPath string
cmd string
expected []string
}{
{
name: "host only",
host: "example.com",
expected: []string{"-o", "ConnectTimeout=5", "example.com"},
name: "host only",
host: "example.com",
cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "example.com", "whoami",
},
},
{
name: "with user",
user: "root",
host: "example.com",
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"},
name: "with user",
user: "root",
host: "example.com",
cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "root@example.com", "whoami",
},
},
{
name: "with port",
host: "example.com",
port: 2222,
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "example.com"},
name: "with port",
host: "example.com",
port: 2222,
cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "example.com", "whoami",
},
},
{
name: "with key",
host: "example.com",
keyPath: "/path/to/key",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "example.com"},
name: "with key",
host: "example.com",
keyPath: "/path/to/key",
cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-i", "/path/to/key", "example.com", "whoami",
},
},
{
name: "user and port",
user: "ubuntu",
host: "192.168.1.10",
port: 2222,
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "ubuntu@192.168.1.10"},
name: "user and port",
user: "ubuntu",
host: "192.168.1.10",
port: 2222,
cmd: "ls -la",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "ubuntu@192.168.1.10", "ls -la",
},
},
{
name: "user and key",
user: "root",
host: "example.com",
keyPath: "/path/to/key",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com"},
name: "user and key",
user: "root",
host: "example.com",
keyPath: "/path/to/key",
cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-i", "/path/to/key", "root@example.com", "whoami",
},
},
{
name: "port and key",
host: "example.com",
port: 22,
keyPath: "~/.ssh/id_rsa",
expected: []string{"-o", "ConnectTimeout=5", "-p", "22", "-i", "~/.ssh/id_rsa", "example.com"},
name: "port and key",
host: "example.com",
port: 22,
keyPath: "~/.ssh/id_rsa",
cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-p", "22", "-i", "~/.ssh/id_rsa", "example.com", "whoami",
},
},
{
name: "all options",
user: "admin",
host: "server.local",
port: 2222,
keyPath: "~/.ssh/id_rsa",
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "~/.ssh/id_rsa", "admin@server.local"},
name: "all options",
user: "admin",
host: "server.local",
port: 2222,
keyPath: "~/.ssh/id_rsa",
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,
keyPath: tt.keyPath,
}
result := remote.buildSSHArgs()
assert.Equal(t, tt.expected, result)
execCmd := remote.newSSHCommand(context.Background(), tt.cmd)
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.
//
//go:fix inline
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"
)
// 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) {
t.Parallel()
@@ -30,12 +25,12 @@ func TestConfigMount_GetNumericUid(t *testing.T) {
{
name: "valid numeric uid",
uid: "1000",
expected: uint64Ptr(1000),
expected: new(uint64(1000)),
},
{
name: "zero uid",
uid: "0",
expected: uint64Ptr(0),
expected: new(uint64(0)),
},
{
name: "invalid non-numeric uid",
@@ -96,12 +91,12 @@ func TestConfigMount_GetNumericGid(t *testing.T) {
{
name: "valid numeric gid",
gid: "1000",
expected: uint64Ptr(1000),
expected: new(uint64(1000)),
},
{
name: "zero gid",
gid: "0",
expected: uint64Ptr(0),
expected: new(uint64(0)),
},
{
name: "invalid non-numeric gid",
+2
View File
@@ -21,6 +21,8 @@ type ContainerResources struct {
Devices []DeviceMapping
// DeviceReservations requests for access to things like GPUs.
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 map[string]Ulimit
}
+9 -11
View File
@@ -69,11 +69,8 @@ type ServiceSpec struct {
Ports []PortSpec
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
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 UpdateConfig `json:",omitempty"`
UpdateConfig UpdateConfig
// Volumes is list of data volumes that can be mounted into the container.
Volumes []VolumeSpec
}
@@ -256,6 +253,8 @@ type ContainerSpec struct {
Init *bool
// LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default.
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 bool
// 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
// Resource allocation for the container.
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
// User overrides the default user of the image used to run the container. Format: user|UID[:group|GID].
User string
@@ -348,9 +350,7 @@ func (s *ContainerSpec) Clone() ContainerSpec {
}
if s.Env != nil {
spec.Env = make(EnvVars, len(s.Env))
for k, v := range s.Env {
spec.Env[k] = v
}
maps.Copy(spec.Env, s.Env)
}
if s.Healthcheck != nil {
hc := *s.Healthcheck
@@ -380,9 +380,7 @@ func (s *ContainerSpec) Clone() ContainerSpec {
}
if s.Sysctls != nil {
spec.Sysctls = make(map[string]string, len(s.Sysctls))
for k, v := range s.Sysctls {
spec.Sysctls[k] = v
}
maps.Copy(spec.Sysctls, s.Sysctls)
}
if s.Resources.Ulimits != nil {
spec.Resources.Ulimits = maps.Clone(s.Resources.Ulimits)
+2 -7
View File
@@ -9,12 +9,6 @@ import (
"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) {
tests := []struct {
name string
@@ -212,6 +206,7 @@ func TestContainerSpec_Clone(t *testing.T) {
original := ContainerSpec{
CapAdd: []string{"NET_ADMIN"},
CapDrop: []string{"ALL"},
PidMode: "host",
Command: []string{"sh", "-c", "echo hello"},
Entrypoint: []string{"/bin/bash"},
Env: EnvVars{
@@ -219,7 +214,7 @@ func TestContainerSpec_Clone(t *testing.T) {
"BAZ": "qux",
},
Image: "nginx:latest",
Init: boolPtr(true),
Init: new(true),
LogDriver: &LogDriver{
Name: "json-file",
Options: map[string]string{
+3 -6
View File
@@ -2,6 +2,7 @@ package api
import (
"fmt"
"maps"
"reflect"
"slices"
"sort"
@@ -165,18 +166,14 @@ func (v *VolumeSpec) Clone() VolumeSpec {
driver := *v.VolumeOptions.Driver
if driver.Options != nil {
driver.Options = make(map[string]string, len(v.VolumeOptions.Driver.Options))
for k, val := range v.VolumeOptions.Driver.Options {
driver.Options[k] = val
}
maps.Copy(driver.Options, v.VolumeOptions.Driver.Options)
}
opts.Driver = &driver
}
if opts.Labels != nil {
opts.Labels = make(map[string]string, len(v.VolumeOptions.Labels))
for k, val := range v.VolumeOptions.Labels {
opts.Labels[k] = val
}
maps.Copy(opts.Labels, v.VolumeOptions.Labels)
}
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
Strategy deploy.Strategy
state *scheduler.ClusterState
plan *operation.SequenceOperation
plan *Plan
}
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
}
func (d *Deployment) Plan(ctx context.Context) (operation.SequenceOperation, error) {
func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
if d.plan != nil {
return *d.plan, nil
}
plan := operation.SequenceOperation{}
var plan Plan
// Generate service specs for all services in the project.
var serviceSpecs []api.ServiceSpec
@@ -90,9 +90,7 @@ func (d *Deployment) Plan(ctx context.Context) (operation.SequenceOperation, err
if err != nil {
return plan, err
}
for _, op := range volumeOps {
plan.Operations = append(plan.Operations, op)
}
plan.Volumes = volumeOps
for _, spec := range serviceSpecs {
// 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.
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
// 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) {
case *MachinesSource:
// 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)
return nil
case []interface{}:
case []any:
// Support interface array that may come from YAML parsing
machineNames := make([]string, 0, len(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),
Image: service.Image,
Init: service.Init,
PidMode: service.Pid,
Privileged: service.Privileged,
PullPolicy: pullPolicy,
Resources: resourcesFromCompose(service),
@@ -87,7 +88,7 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
if service.StopGracePeriod != nil {
d := time.Duration(*service.StopGracePeriod)
spec.StopGracePeriod = &d
spec.Container.StopGracePeriod = &d
}
if service.Scale != nil {
@@ -177,6 +178,7 @@ func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
CPU: int64(service.CPUS * 1e9),
Memory: int64(service.MemLimit),
MemoryReservation: int64(service.MemReservation),
SharedMemory: int64(service.ShmSize),
Ulimits: ulimitsFromCompose(service.Ulimits),
}
@@ -345,9 +347,7 @@ func dockerVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig, volume
func mergeLabels(labels ...types.Labels) types.Labels {
merged := types.Labels{}
for _, l := range labels {
for k, v := range l {
merged[k] = v
}
maps.Copy(merged, l)
}
return merged
}
+4 -2
View File
@@ -127,12 +127,14 @@ func TestServiceSpecFromCompose(t *testing.T) {
"max-file": "3",
},
},
PidMode: "host",
Privileged: true,
PullPolicy: api.PullPolicyAlways,
Resources: api.ContainerResources{
CPU: 0.5 * api.Core,
Memory: 100 * units.MiB,
MemoryReservation: 50 * units.MiB,
SharedMemory: 256 * units.MiB,
Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 20000, Hard: 40000},
"nproc": {Soft: 65535, Hard: 65535},
@@ -145,6 +147,7 @@ func TestServiceSpecFromCompose(t *testing.T) {
{Count: -1, Capabilities: [][]string{{"gpu"}}},
},
},
StopGracePeriod: new(30 * time.Second),
Sysctls: map[string]string{
"net.ipv4.ip_forward": "1",
},
@@ -205,8 +208,7 @@ func TestServiceSpecFromCompose(t *testing.T) {
Placement: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
Replicas: 3,
StopGracePeriod: api.AsPtr(30 * time.Second),
Replicas: 3,
UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
+2
View File
@@ -6,6 +6,7 @@ services:
- ALL
command: ["nginx", "updated", "command"]
cpus: 0.5
pid: host
deploy:
update_config:
order: stop-first
@@ -34,6 +35,7 @@ services:
max-file: 3
mem_limit: 100M
mem_reservation: 50M
shm_size: 268435456
privileged: true
pull_policy: always
scale: 3
+19
View File
@@ -47,6 +47,25 @@ func TestEvalContainerSpecChange_ContainerCapDrop(t *testing.T) {
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) {
t.Parallel()
+237 -9
View File
@@ -4,7 +4,13 @@ import (
"context"
"errors"
"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/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
@@ -26,17 +32,239 @@ type Deployment struct {
Spec api.ServiceSpec
Strategy Strategy
cli Client
plan *Plan
plan *ServicePlan
// state is an optional current and planned cluster state used for scheduling decisions.
state *scheduler.ClusterState
}
type Plan struct {
type ServicePlan struct {
ServiceID 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
}
// 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.
// If strategy is nil, a default RollingStrategy will be used.
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.
// 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 {
return *d.plan, nil
}
// Validate the user-provided spec before resolving it.
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)
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{
// 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)
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 {
d.state, err = scheduler.InspectClusterState(ctx, d.cli)
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)
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
@@ -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
// the existing one to match the desired specification.
// 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)
if err != nil {
return plan, fmt.Errorf("create plan: %w", err)
+55 -21
View File
@@ -8,6 +8,7 @@ import (
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
)
@@ -16,6 +17,8 @@ type RunContainerOperation struct {
ServiceID string
Spec api.ServiceSpec
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 bool
}
@@ -42,9 +45,12 @@ func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error {
return nil
}
func (o *RunContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Run container [image=%s]", machineName, o.Spec.Container.Image)
func (o *RunContainerOperation) Format() string {
return tui.BoldGreen.Render("+") + " " +
tui.Faint.Render("run container") + " " +
o.Spec.Name + " " +
tui.Faint.Render("on") + " " +
o.MachineName
}
func (o *RunContainerOperation) String() string {
@@ -54,9 +60,11 @@ func (o *RunContainerOperation) String() string {
// StopContainerOperation stops a container on a specific machine.
type StopContainerOperation struct {
ServiceID string
ContainerID string
MachineID string
ServiceID string
ContainerID string
MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
StopGracePeriod *time.Duration
}
@@ -67,10 +75,15 @@ func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error
return nil
}
func (o *StopContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Stop container [id=%s name=%s]", machineName,
o.ContainerID[:12], resolver.ContainerName(o.ContainerID))
func (o *StopContainerOperation) Format() string {
// TODO: pass service name to format the display name consistently with other operations.
displayName := stringid.TruncateID(o.ContainerID)
return tui.BoldRed.Render("-") + " " +
tui.Faint.Render("stop container") + " " +
displayName + " " +
tui.Faint.Render("on") + " " +
o.MachineName
}
func (o *StopContainerOperation) String() string {
@@ -80,7 +93,9 @@ func (o *StopContainerOperation) String() string {
// RemoveContainerOperation stops and removes a container from a specific machine.
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
StopGracePeriod *time.Duration
}
@@ -101,10 +116,14 @@ func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) erro
return nil
}
func (o *RemoveContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Remove container [id=%s image=%s]",
machineName, o.Container.ShortID(), o.Container.Config.Image)
func (o *RemoveContainerOperation) Format() string {
displayName := o.Container.ServiceSpec.Name + tui.Faint.Render("/") + o.Container.ShortID()
return tui.BoldRed.Render("-") + " " +
tui.Faint.Render("remove container") + " " +
displayName + " " +
tui.Faint.Render("on") + " " +
o.MachineName
}
func (o *RemoveContainerOperation) String() string {
@@ -116,9 +135,11 @@ func (o *RemoveContainerOperation) String() string {
// For start-first: starts new container, then removes old container.
// For stop-first: stops old container, starts new container, then removes old container.
type ReplaceContainerOperation struct {
ServiceID string
Spec api.ServiceSpec
MachineID string
ServiceID string
Spec api.ServiceSpec
MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
OldContainer api.ServiceContainer
// Order specifies the update order: "start-first" or "stop-first".
Order string
@@ -207,9 +228,22 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err
return nil
}
func (o *ReplaceContainerOperation) Format(resolver NameResolver) string {
return fmt.Sprintf("%s: Replace container [id=%s image=%s order=%s]",
resolver.MachineName(o.MachineID), o.OldContainer.ShortID(), o.Spec.Container.Image, o.Order)
func (o *ReplaceContainerOperation) Format() string {
displayName := o.Spec.Name + tui.Faint.Render("/") + o.OldContainer.ShortID()
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 {
+1 -8
View File
@@ -12,8 +12,7 @@ type Operation interface {
// Execute performs the operation using the provided client.
Execute(ctx context.Context, cli Client) error
// 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(resolver NameResolver) string
Format() string
String() string
}
@@ -22,9 +21,3 @@ type Client interface {
api.ContainerClient
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
}
func (o *SequenceOperation) Format(resolver NameResolver) string {
ops := make([]string, len(o.Operations))
func (o *SequenceOperation) Format() string {
lines := make([]string, len(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 {
+8 -3
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
)
@@ -12,7 +13,7 @@ import (
type CreateVolumeOperation struct {
VolumeSpec api.VolumeSpec
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
}
@@ -39,8 +40,12 @@ func (o *CreateVolumeOperation) Execute(ctx context.Context, cli Client) error {
return nil
}
func (o *CreateVolumeOperation) Format(_ NameResolver) string {
return fmt.Sprintf("%s: Create volume [name=%s]", o.MachineName, o.VolumeSpec.DockerVolumeName())
func (o *CreateVolumeOperation) Format() string {
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 {
+60 -25
View File
@@ -20,7 +20,7 @@ type Strategy interface {
// 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
// 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
@@ -40,9 +40,9 @@ func (s *RollingStrategy) Type() string {
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 {
return Plan{}, fmt.Errorf("cluster state must be provided")
return ServicePlan{}, fmt.Errorf("cluster state must be provided")
}
s.state = state
@@ -53,7 +53,7 @@ func (s *RollingStrategy) Plan(state *scheduler.ClusterState, svc *api.Service,
case api.ServiceModeGlobal:
return s.planGlobal(svc, spec)
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
// in the cluster.
// 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) {
plan, err := newEmptyPlan(svc, spec)
func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
plan, err := newEmptyServicePlan(svc, spec)
if err != nil {
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)
// TODO: return a detailed report on required constraints and which ones are satisfied?
availableMachines, err := sched.EligibleMachines()
@@ -151,6 +157,7 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
ServiceID: plan.ServiceID,
Spec: spec,
MachineID: m.Id,
MachineName: m.Name,
SkipHealthMonitor: s.SkipHealthMonitor,
})
continue
@@ -172,10 +179,11 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
ServiceID: plan.ServiceID,
Spec: spec,
MachineID: m.Id,
MachineName: m.Name,
OldContainer: ctr,
Order: order,
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 {
plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
MachineID: mid,
MachineName: machineNames[mid],
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.
// It handles multiple containers per machine (though this should not occur in normal operation) and skips machines
// that are down.
func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Plan, error) {
plan, err := newEmptyPlan(svc, spec)
func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
plan, err := newEmptyServicePlan(svc, spec)
if err != nil {
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
// 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.
@@ -223,7 +238,13 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
for _, m := range availableMachines {
containers := containersOnMachine[m.Info.Id]
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 {
return plan, err
}
@@ -237,8 +258,9 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
for _, c := range containers {
plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
MachineID: c.MachineID,
MachineName: machineNames[c.MachineID],
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
// removing old ones. If there is a host port conflict, it stops the old container before starting a new one.
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,
) ([]operation.Operation, error) {
var ops []operation.Operation
@@ -260,7 +285,8 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.RunContainerOperation{
ServiceID: serviceID,
Spec: spec,
MachineID: machineID,
MachineID: machine.Id,
MachineName: machine.Name,
SkipHealthMonitor: skipHealthCheck,
})
return ops, nil
@@ -290,8 +316,9 @@ func reconcileGlobalContainer(
}
ops = append(ops, &operation.RemoveContainerOperation{
MachineID: old.MachineID,
MachineName: machine.Name,
Container: old.Container,
StopGracePeriod: spec.StopGracePeriod,
StopGracePeriod: spec.Container.StopGracePeriod,
})
}
break
@@ -325,8 +352,9 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.StopContainerOperation{
ServiceID: serviceID,
ContainerID: c.Container.ID,
MachineID: machineID,
StopGracePeriod: spec.StopGracePeriod,
MachineID: machine.Id,
MachineName: machine.Name,
StopGracePeriod: spec.Container.StopGracePeriod,
})
}
}
@@ -336,11 +364,12 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.ReplaceContainerOperation{
ServiceID: serviceID,
Spec: spec,
MachineID: machineID,
MachineID: machine.Id,
MachineName: machine.Name,
OldContainer: containerToReplace.Container,
Order: order,
SkipHealthMonitor: skipHealthCheck,
StopGracePeriod: spec.StopGracePeriod,
StopGracePeriod: spec.Container.StopGracePeriod,
})
// Remove any other containers (there shouldn't be any in normal operation).
@@ -350,8 +379,9 @@ func reconcileGlobalContainer(
}
ops = append(ops, &operation.RemoveContainerOperation{
MachineID: c.MachineID,
MachineName: machine.Name,
Container: c.Container,
StopGracePeriod: spec.StopGracePeriod,
StopGracePeriod: spec.Container.StopGracePeriod,
})
}
} else {
@@ -359,14 +389,16 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.RunContainerOperation{
ServiceID: serviceID,
Spec: spec,
MachineID: machineID,
MachineID: machine.Id,
MachineName: machine.Name,
SkipHealthMonitor: skipHealthCheck,
})
for _, c := range containers {
ops = append(ops, &operation.RemoveContainerOperation{
MachineID: c.MachineID,
MachineName: machine.Name,
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
}
// newEmptyPlan 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) {
var plan Plan
// newEmptyServicePlan creates a new empty plan for a service deployment with initialised service ID and name.
func newEmptyServicePlan(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
plan := ServicePlan{
Spec: spec,
}
// Generate a new service ID for the initial service deployment if it doesn't exist yet.
if svc != nil {
plan.ServiceID = svc.ID
plan.ServiceName = svc.Name
} else {
plan.IsNewService = true
var err error
plan.ServiceID, err = secret.NewID()
if err != nil {
+20 -7
View File
@@ -6,6 +6,7 @@ import (
"github.com/docker/docker/api/types/container"
"github.com/google/go-cmp/cmp"
"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/client/deploy/operation"
"github.com/stretchr/testify/assert"
@@ -315,8 +316,9 @@ func TestReconcileGlobalContainer(t *testing.T) {
},
expectedOps: []operation.Operation{
&operation.RunContainerOperation{
ServiceID: "service-1",
MachineID: "machine-1",
ServiceID: "service-1",
MachineID: "machine-1",
MachineName: "machine-1",
},
},
},
@@ -335,6 +337,7 @@ func TestReconcileGlobalContainer(t *testing.T) {
&operation.ReplaceContainerOperation{
ServiceID: "service-1",
MachineID: "machine-1",
MachineName: "machine-1",
OldContainer: container1,
Order: api.UpdateOrderStopFirst,
},
@@ -358,16 +361,19 @@ func TestReconcileGlobalContainer(t *testing.T) {
ServiceID: "service-1",
ContainerID: "container-2",
MachineID: "machine-1",
MachineName: "machine-1",
},
&operation.ReplaceContainerOperation{
ServiceID: "service-1",
MachineID: "machine-1",
MachineName: "machine-1",
OldContainer: container1,
Order: api.UpdateOrderStopFirst,
},
&operation.RemoveContainerOperation{
MachineID: "machine-1",
Container: container2WithPort9090,
MachineID: "machine-1",
MachineName: "machine-1",
Container: container2WithPort9090,
},
},
},
@@ -388,12 +394,14 @@ func TestReconcileGlobalContainer(t *testing.T) {
&operation.ReplaceContainerOperation{
ServiceID: "service-1",
MachineID: "machine-1",
MachineName: "machine-1",
OldContainer: container1,
Order: api.UpdateOrderStopFirst,
},
&operation.RemoveContainerOperation{
MachineID: "machine-1",
Container: container2WithPort3000,
MachineID: "machine-1",
MachineName: "machine-1",
Container: container2WithPort3000,
},
},
},
@@ -402,7 +410,12 @@ func TestReconcileGlobalContainer(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
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)
assertOperationsEqual(t, tt.expectedOps, ops)
+5 -7
View File
@@ -12,7 +12,7 @@ import (
"sync"
"time"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
"github.com/containerd/errdefs"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container"
@@ -474,12 +474,10 @@ func toPushProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
percent := 0
if jm.Progress.Total > 0 {
percent = int(jm.Progress.Current * 100 / jm.Progress.Total)
// Cap percent at 100 to prevent index out of bounds in progress display.
// Docker can report Current > Total in some cases (e.g., compression).
if percent > 100 {
percent = 100
}
percent = min(
// Cap percent at 100 to prevent index out of bounds in progress display.
// Docker can report Current > Total in some cases (e.g., compression).
int(jm.Progress.Current*100/jm.Progress.Total), 100)
}
switch jm.Status {
+1 -1
View File
@@ -264,7 +264,7 @@ func TestLogMerger_UnevenStreams(t *testing.T) {
baseTime := time.Now()
// 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")
}
-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/docker/compose/v2/pkg/progress"
"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/pkg/api"
)
@@ -70,7 +71,7 @@ func (cli *Client) ListVolumes(ctx context.Context, filter *api.VolumeFilter) ([
for _, mv := range machineVolumes {
if mv.Metadata != nil && mv.Metadata.Error != "" {
// 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))
continue
}
+5 -3
View File
@@ -39,7 +39,8 @@ func TestComposeConfigs(t *testing.T) {
plan, err := deploy.Plan(ctx)
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)
require.NoError(t, err)
@@ -93,7 +94,7 @@ func TestComposeConfigs(t *testing.T) {
plan, err = deploy.Plan(ctx)
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
containerName := svc.Containers[0].Container.Name
@@ -116,7 +117,8 @@ func TestComposeConfigs(t *testing.T) {
groupId: 1000,
}, 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)
assert.Equal(t, fileInfo{
permissions: 0o644,
+12 -10
View File
@@ -40,7 +40,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx)
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)
require.NoError(t, err)
@@ -93,7 +93,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx)
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)
require.NoError(t, err)
@@ -168,7 +168,7 @@ func TestComposeDeployment(t *testing.T) {
redeployPlan, err := redeploy.Plan(ctx)
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.
strategy := &deploy.RollingStrategy{ForceRecreate: true}
@@ -177,7 +177,7 @@ func TestComposeDeployment(t *testing.T) {
recreatePlan, err := recreateDeploy.Plan(ctx)
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)
require.NoError(t, err)
@@ -242,7 +242,8 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx)
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)
require.NoError(t, err)
@@ -384,7 +385,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err = deployment.Plan(ctx)
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) {
@@ -403,7 +404,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx)
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)
require.NoError(t, err)
@@ -456,7 +457,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx)
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)
require.NoError(t, err)
@@ -504,7 +505,7 @@ func TestComposeDeployment(t *testing.T) {
plan, err := deployment.Plan(ctx)
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)
require.NoError(t, err)
@@ -556,7 +557,8 @@ volumes:
plan, err := deployment.Plan(ctx)
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) {
+1 -1
View File
@@ -1052,7 +1052,7 @@ myapp.example.com {
// Machine 0: vol1, vol2
// Machine 1: vol1, vol2
// 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})
require.NoError(t, err)
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
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,71 +1,88 @@
# Compose support matrix
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.
| Feature | Support Status | Notes |
|---------------------|--------------------|----------------------------------------------------------------------------------------------------------------|
| **Services** | | |
| `build` | ✅ Supported | Build context and Dockerfile |
| `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) |
| `cap_drop` | ✅ Supported | Which kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) to drop |
| `command` | ✅ Supported | Override container command |
| `configs` | Supported | File-based and inline configs |
| `cpus` | ✅ Supported | CPU limit |
| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked |
| `devices` | ✅ Supported | Device mappings |
| `dns` | ❌ Not supported | Built-in service discovery |
| `dns_search` | ❌ Not supported | Built-in service discovery |
| `entrypoint` | ✅ Supported | Override container entrypoint |
| `env_file` | ✅ Supported | Environment file |
| `environment` | ✅ Supported | Environment variables |
| `gpus` | ✅ Supported | GPU device access |
| `healthcheck` | ✅ Supported | Health check configuration |
| `image` | ✅ Supported | Container image specification |
| `init` | ✅ Supported | Run init process in container |
| `labels` | ❌ Not supported | |
| `links` | ❌ Not supported | Use service names for communication |
| `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver |
| `mem_limit` | ✅ Supported | Memory limit |
| `mem_reservation` | ✅ Supported | Memory reservation |
| `mem_swappiness` | ❌ Not supported | |
| `memswap_limit` | ❌ Not supported | |
| `networks` | ❌ Not supported | All containers share cluster network |
| `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](#x-ports) for HTTP/HTTPS |
| `privileged` | ✅ Supported | Run containers in privileged mode |
| `pull_policy` | ✅ Supported | `always`, `missing`, `never` |
| `secrets` | ❌ Not supported | Use configs or environment variables |
| `security_opt` | ❌ Not supported | |
| `stop_grace_period` | ✅ Supported | Time to wait after SIGTERM before SIGKILL |
| `storage_opt` | ❌ Not supported | |
| `sysctls` | ✅ Supported | Namespaced kernel parameters |
| `user` | ✅ Supported | Set container user |
| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs |
| **Deploy** | | |
| `labels` | ❌ Not supported | |
| `mode` | ✅ Supported | Either `global` or `replicated` |
| `placement` | ❌ Not supported | Use [`x-machines`](#x-machines) extension |
| `replicas` | ✅ Supported | Number of container replicas |
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
| `update_config` | ⚠️ Limited | `order` and `monitor` supported. See [rolling deployments](../4-guides/1-deployments/4-rolling-deployments.md) |
| **Volumes** | | |
| Named volumes | ✅ Supported | Docker volumes |
| Bind mounts | ✅ Supported | Host path binding |
| Tmpfs mounts | ✅ Supported | In-memory filesystems |
| Volume labels | ✅ Supported | Custom labels |
| External volumes | ✅ Supported | Must exist before deployment |
| Volume drivers | ⚠️ Limited | Local driver only |
| **Configs** | | |
| File-based configs | ✅ Supported | Read from file |
| Inline configs | ✅ Supported | Defined in compose file |
| External configs | ❌ Not supported | Not supported |
| Short syntax | ❌ Not supported | Use long syntax only |
| **Extensions** | | |
| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration |
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints |
| `x-ports` | ✅ Uncloud-specific | Service port publishing |
:::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 |
|----------------------------------|--------------------|----------------------------------------------------------------------------------------------------------------|
| **Services** | | |
| `build` | ✅ Supported | Build context and Dockerfile |
| `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) |
| `cap_drop` | ✅ Supported | Which kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) to drop |
| `command` | ✅ Supported | Override container command |
| `configs` | ✅ Supported | File-based and inline configs |
| `cpus` | ✅ Supported | CPU limit |
| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked |
| `devices` | ✅ Supported | Device mappings |
| `dns` | ❌ Not supported | Built-in service discovery |
| `dns_search` | ❌ Not supported | Built-in service discovery |
| `entrypoint` | ✅ Supported | Override container entrypoint |
| `env_file` | ✅ Supported | Environment file |
| `environment` | ✅ Supported | Environment variables |
| `gpus` | ✅ Supported | GPU device access |
| `healthcheck` | ✅ Supported | Health check configuration |
| `image` | ✅ Supported | Container image specification |
| `init` | ✅ Supported | Run init process in container |
| `labels` | ❌ Not supported | |
| `links` | ❌ Not supported | Use service names for communication |
| `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver |
| `mem_limit` | ✅ Supported | Memory limit |
| `mem_reservation` | ✅ Supported | Memory reservation |
| `mem_swappiness` | ❌ Not supported | |
| `memswap_limit` | ❌ Not supported | |
| `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 |
| `privileged` | ✅ Supported | Run containers in privileged mode |
| `pull_policy` | ✅ Supported | `always`, `missing`, `never` |
| `secrets` | ❌ Not supported | Use configs or environment variables |
| `security_opt` | ❌ Not supported | |
| `shm_size` | ✅ Supported | Shared memory size |
| `stop_grace_period` | ✅ Supported | Time to wait after SIGTERM before SIGKILL |
| `storage_opt` | ❌ Not supported | |
| `sysctls` | ✅ Supported | Namespaced kernel parameters |
| `ulimits` | ✅ Supported | Resource limits |
| `user` | ✅ Supported | Set container user |
| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs |
| **Deploy** | | |
| `labels` | ❌ Not supported | |
| `mode` | ✅ Supported | Either `global` or `replicated` |
| `placement` | ❌ Not supported | Use [`x-machines`](#x-machines) extension |
| `replicas` | ✅ Supported | Number of container replicas |
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
| `update_config` | ⚠️ Limited | `order` and `monitor` supported. See [rolling deployments](../4-guides/1-deployments/4-rolling-deployments.md) |
| **Volumes** | | |
| Named volumes | ✅ Supported | Docker volumes |
| Bind mounts | ✅ Supported | Host path binding |
| Tmpfs mounts | ✅ Supported | In-memory filesystems |
| Volume labels | ✅ Supported | Custom labels |
| External volumes | ✅ Supported | Must exist before deployment |
| [Volume drivers][volume-drivers] | ✅ Supported | `local` (supports [NFS][volume-nfs], [CIFS/Samba][volume-cifs]) and manually installed third-party drivers |
| **Configs** | | |
| File-based configs | ✅ Supported | Read from file |
| Inline configs | ✅ Supported | Defined in compose file |
| External configs | ❌ Not supported | Not supported |
| Short syntax | ❌ Not supported | Use long syntax only |
| **Extensions** | | |
| `x-context` | ✅ Uncloud-specific | Cluster context override |
| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration |
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints |
| `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
@@ -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:
### `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`
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.
-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)
--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)
--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.
@@ -31,6 +32,14 @@ uc run IMAGE [COMMAND...] [flags]
-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")
--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.
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
+3 -1
View File
@@ -4,7 +4,7 @@ Scale a replicated service by changing the number of replicas.
## 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]
@@ -14,6 +14,8 @@ uc scale SERVICE REPLICAS [flags]
```
-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
+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.
-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)
--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)
--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.
@@ -31,6 +32,14 @@ uc service run IMAGE [COMMAND...] [flags]
-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")
--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.
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
@@ -4,7 +4,7 @@ Scale a replicated service by changing the number of replicas.
## 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]
@@ -14,6 +14,8 @@ uc service scale SERVICE REPLICAS [flags]
```
-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