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