Compare commits

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

Fixes: #284

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

`uc machine ls` also works.

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

Modelled after #238.

See: #237

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

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

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

Signed-off-by: Miek Gieben <miek@miek.nl>
2026-03-21 21:16:09 +10:00
Pasha Sviderski a5baf6e5c3 docs: regenerate CLI reference 2026-03-19 17:45:39 +10:00
Pasha Sviderski 6c75277e59 docs: add HACKING.md guide on setting up a local dev environment 2026-03-19 17:35:55 +10:00
Pasha Sviderski 76880d901a chore: move proto generation to mise tasks 2026-03-19 17:32:48 +10:00
Pasha Sviderski 3f95583dd4 fix: plan formatting for service scaling (update verb), fix image and replicas diff 2026-03-19 15:29:21 +10:00
Pasha Sviderski f5a77b82b2 feat: add auto-confirm option for 'uc scale' command, always require confirmation, format scaling plan 2026-03-19 14:41:04 +10:00
Pasha Sviderski b4b1232375 refactor: format deployment plan for caddy (uc caddy deploy) 2026-03-19 14:22:01 +10:00
Pasha Sviderski e4cb71b878 refactor: simplify and speed up plan output formatting by removing name resolver dependency 2026-03-19 13:14:09 +10:00
Pasha Sviderski 95f80a7fda docs: correct Docker volume drivers supported, add links 2026-03-19 11:30:00 +10:00
442f1d97a1 fix: uc deploy should not crash in empty git repository (#275)
Co-authored-by: Anton Ovchinnikov <anton@tonyo.info>
2026-03-19 10:16:03 +10:00
Pasha Sviderski d2703cbc6b fix: remove extra line between services in deploy plan 2026-03-18 21:41:54 +10:00
Pasha Sviderski 0f4c211002 refactor: format compose deployment plan with style, display target context 2026-03-18 21:26:22 +10:00
Pasha Sviderski 68661e57c3 refactor: tui package for CLI styles and prompts, restyle confirmation 2026-03-18 16:25:25 +10:00
Pasha Sviderski 1735ef9521 refactor: GetContextOverrideOrCurrent to check if config used 2026-03-17 20:21:54 +10:00
Pasha Sviderski 838839a7e5 refactor: update deployment plan structure to use typed volume and service operations 2026-03-16 17:54:01 +10:00
Pasha Sviderski 940732e91c fix: sshcli unit test and lint 2026-03-14 15:04:50 +10:00
Pasha Sviderski 9e4c759ffc fix: separate stdout and stderr in SSH CLI executor to prevent output contamination (closes #270) 2026-03-14 14:56:04 +10:00
Pasha Sviderski 1525d182b5 feat: add cluster context support in Compose with x-context extension 2026-03-13 18:37:36 +10:00
Pasha Sviderski 8ae38cfd57 feat: add shm_size support in Compose and --shm-size flag for 'uc run' (closes #267) 2026-03-12 22:09:28 +10:00
Pasha Sviderski 6cc3a05ed2 chore: bump mise to 2026.3.7 on github actions 2026-03-11 17:56:06 +10:00
Pasha Sviderski 29a682f1ce chore: generate mise.lock for all platforms 2026-03-11 17:53:04 +10:00
Pasha Sviderski b2fce73254 docs: regenerate CLI reference 2026-03-11 17:08:51 +10:00
Pasha Sviderski 7f92a5db27 feat: add --ulimit flag for 'uc run' command 2026-03-11 17:04:11 +10:00
Pasha Sviderski a5c9085105 docs: add ulimits to Compose compatibility doc 2026-03-11 16:58:15 +10:00
Pasha Sviderski d344392c1b docs: add note on Compose compatibility doc to submit feature requests 2026-03-11 16:30:56 +10:00
Pasha Sviderski 2992bad05d chore: bump Go to 1.26.1 and golangci-lint to 2.11.2 on github actions 2026-03-10 19:50:05 +10:00
Pasha Sviderski 255448a59c chore: migrate to hub/v2 and bubbletea/v2 2026-03-10 19:47:25 +10:00
Pasha Sviderski cf3234aef9 chore: bump Go version to 1.26.1 2026-03-10 19:18:09 +10:00
Pasha Sviderski 9e39f03f75 chore: lint 2026-03-10 18:57:51 +10:00
Pasha Sviderski 33fcb2fe74 chore: migrate to lipgloss/v2 2026-03-10 18:54:34 +10:00
Pasha Sviderski d5687cd713 chore: combine some e2e tests to reduce the number of ucind clusters hence used system resources 2026-03-09 19:07:00 +10:00
Pasha Sviderski a67f936cc1 chore: wait for known missing changes during the initial cluster store sync 2026-03-09 17:40:51 +10:00
Pasha Sviderski 51b8c29d63 fix: typo 2026-03-09 17:38:37 +10:00
Pasha Sviderski e6f013dbc8 fix: skip Tailscale addresses for using as auto-detected WireGuard endpoints 2026-03-09 17:38:25 +10:00
Pasha Sviderski 897f03aafd chore: skip not found services when streaming logs for Compose services (print warning) 2026-03-09 09:33:33 +10:00
Pasha Sviderski 7b306f25ce fix: another attempt to fix flaky test container_crashes_on_startup_without_healthcheck 2026-03-06 21:12:23 +10:00
Pasha Sviderski 78a3551c77 docs: regenerate CLI reference 2026-03-06 21:01:38 +10:00
Pasha Sviderski 38c7dda6a0 chore: rename --endpoint to --wg-endpoint for 'uc machine update' command 2026-03-06 15:45:13 +10:00
Pasha Sviderski 51c7b5ba8f feat: add --wg-endpoint flag to 'uc machine add/init' to specify wireguard endpoint for the machine instead of auto-detecting 2026-03-06 15:39:14 +10:00
GabrielandGitHub a507b202ec fix: create AAAA DNS record for IPv6 machines" (#257)
* chore: update gitignore
* fix: create AAAA DNS record for IPv6 machines"
* test: randomize execution order to prevent order-based bugs in tests
2026-03-04 15:17:27 +10:00
Pasha Sviderski d9934b9dba feat: support compose stop_grace_period to change timeout before sending SIGKILL 2026-03-04 14:07:22 +10:00
Pasha Sviderski 1f328e99d5 fix: remove the incorrect e2e test for updating machine endpoints with empty list 2026-03-04 14:04:46 +10:00
Pasha Sviderski 3c435434a8 fix: flaky test TestDeployment/container_crashes_on_startup_without_healthcheck 2026-03-04 14:03:29 +10:00
Pasha Sviderski e54871758b chore: increase wait for test cluster timeout 30s -> 60s for e2e tests on GHA 2026-03-04 13:07:09 +10:00
Pasha Sviderski c039390ade docs: regenerate CLI reference 2026-03-04 12:47:21 +10:00
Pasha Sviderski bad0f97b66 feat: add support for updating WireGuard endpoints for a machine (uc machine update) 2026-03-03 20:21:27 +10:00
Pasha Sviderski 2405b156e4 chore: validate endpoints is not empty in update machine requests 2026-03-03 19:04:41 +10:00
Pasha Sviderski 4de17cb63c chore: delete the obsolete 'machine token' command 2026-03-03 18:03:06 +10:00
Pasha Sviderski 2222434934 fix: connection formatting when changing connection with 'ctx conn' 2026-03-03 17:26:42 +10:00
Pasha Sviderski 3f1a772288 docs: minor polish for rolling deployments doc 2026-03-03 16:39:43 +10:00
Pasha Sviderski 7b9bed0780 docs: update demo thumbnail in README 2026-03-03 15:01:59 +10:00
Pasha Sviderski aef4b2213f docs: rewrite Rolling deployments doc from Deployment strategies 2026-03-01 19:51:47 +10:00
Pasha Sviderski 44013da332 chore: format AGENTS, instruct to not use em dashes 2026-03-01 19:38:23 +10:00
Pasha Sviderski b48ebe2acc chore: UNCLOUD_HEALTH_MONITOR_PERIOD accepts duration (10s, 500ms, 0) 2026-03-01 08:08:57 +10:00
Pasha Sviderski eaa4455320 docs: generate CLI reference docs 2026-02-28 16:29:05 +10:00
Pasha Sviderski ce3cb8a34f feat: add --skip-health flag to bypass health monitoring during container deployment 2026-02-28 16:26:48 +10:00
Pasha Sviderski e6705599b3 chore: rename env var UNCLOUD_DEFAULT_HEALTH_MONITOR_PERIOD_MS -> UNCLOUD_HEALTH_MONITOR_PERIOD_MS 2026-02-28 16:03:36 +10:00
Pasha Sviderski 7b88040c73 fix: rolling container replacement: if new container fails, rollback and run old container only if it was running 2026-02-28 14:29:48 +10:00
Pasha Sviderski 7a9eac593f feat: implement container health monitoring and rollback during rolling deployment (closes #24) 2026-02-27 21:36:40 +10:00
Pasha Sviderski 17c1bb5c21 feat: add WaitContainerHealthy client method and health check utilities 2026-02-27 21:28:11 +10:00
Pasha Sviderski a5a9ef5444 chore: init container healthcheck const 2026-02-26 19:39:49 +10:00
Pasha Sviderski 2940965e31 fix: recreate container on ulimit changes, add test 2026-02-25 16:38:44 +10:00
Pasha Sviderski 185cf19a0c chore: minor full-spec test change 2026-02-25 16:03:28 +10:00
Pasha Sviderski 4443fa9e0e chore: update full-spec test to include latest implemented Compose attributes 2026-02-25 16:01:31 +10:00
Pasha Sviderski 3056e2af11 feat: add support for healthcheck in Compose 2026-02-25 14:54:24 +10:00
Pasha Sviderski 7bfaf31138 fix: image tag template formatting in docs 2026-02-24 16:42:55 +10:00
Pasha Sviderski f8d1945544 chore: add TODO for ReplaceContainerOperation 2026-02-24 15:30:23 +10:00
Pasha Sviderski 93d7f29d31 chore: remove TODO about encapsulating Client in deploy operations 2026-02-24 13:17:59 +10:00
Pasha Sviderski 18ebd29032 chore: split deploy operations into separate files in operation package 2026-02-24 11:42:47 +10:00
Pasha Sviderski fe56d6d708 chore: always run proxy to virtualised Docker on macOS for 'image push' except OrbStack 2026-02-23 20:06:23 +10:00
14056cc584 fix: format IPv6 addr correclty for HTTP URL (#254)
* fix: format IPv6 addr correclty for HTTP URL

* test: cover changes

* test: refactor

---------

Co-authored-by: gm0stache <gm0stache@noreply.codeberg.org>
2026-02-16 21:19:52 +01:00
Dimitar DimitrovandGitHub d3ee7399eb fix: support image push when using Rancher Desktop locally (#251)
Rancher Desktop is a free alternative to Docker Desktop (which prohibits
commercial use) and has gained significant popularity. When running
Rancher Desktop on non-native Docker platforms such as macOS and
Windows, the secondary tunnel from the host OS to the Docker's Linux
machine is necessary. At the moment, the check for this seems to be
based on the vhost name of the docker engine's VM.

For Rancher Desktop it seemms to be `lima-rancher-desktop`:

  $ docker info --format 'json' | jq .Name

This might not be the best approach but is simple and should save a lot
of headaches for a lot of people until a potentially better solution is
devised.

See also: https://github.com/psviderski/unregistry/issues/62
2026-02-15 23:04:57 +01:00
Pasha Sviderski 5c54e9d059 chore: refactor device mapping to be compliant with Compose (CDI requests) 2026-02-11 17:44:38 +00:00
Justin BradfordandGitHub ab6f856987 feat: Add support for compose devices mappings (#250)
* feat: Add support for compose `devices` mappings

https://docs.docker.com/reference/compose-file/services/#devices
```
services:
  foo:
    devices:
      - "/dev/ttyUSB0:/dev/ttyUSB0"
      - "/dev/sda:/dev/xvda:rwm"
```

* Lint fix
2026-02-11 17:00:18 +00:00
Pasha Sviderski 6e59657fe2 chore: global reconciliation: stop all running conflicting containers, not only replace the first one 2026-02-09 22:29:25 +00:00
Pasha Sviderski 215f21439d chore: lint and fix e2e deploy tests with ReplaceContainerOperation 2026-02-09 09:53:51 +00:00
NickandGitHub 53bf446502 deploy.update_config.order support to start-first or stop-first when replacing containers (#248) 2026-02-09 09:31:19 +00:00
Pasha Sviderski 2588279e52 chore: rename AI.md to AGENTS.md 2026-02-03 18:32:12 +01:00
Pasha Sviderski f48dc250a9 chore: fix landing navbar github/discord buttons wrap on mobile 2026-02-03 17:23:38 +01:00
Zasda Yusuf MikailandGitHub 15d9ceb4d1 feat: auto-provision volumes on all machines for global services (#243)
* feat: auto-provision volumes on all machines for global services

* fix: schedule global service volumes only on machines that need them

* fix: use union of eligible machines for volumes shared by global services

* test: restore global with missing volume fails test

* refactor: simplify volume scheduler by removing redundant check

Move isVolumeSharedBetweenGlobalAndReplicated check earlier to fail
fast, then use isVolumeForGlobalService instead of the now-redundant
isVolumeOnlyForGlobalServices function.
2026-02-02 12:30:57 +01:00
Pasha Sviderski a4a8e70f9c fix: sshcli tests 2026-01-28 19:00:53 +10:00
Pasha Sviderski 1d4dbd96ce chore: minor sshcli refactgor 2026-01-28 18:17:55 +10:00
Pasha Sviderski 476a2d57ca fix: reset already initialised machine on 'uc machine init' when using ssh+cli connection 2026-01-28 18:08:57 +10:00
Pasha Sviderski ce46a67d6d fix: ssh args for machine init/add when using ssh+cli 2026-01-28 17:44:54 +10:00
Pasha Sviderski 87e49dfb62 chore: label temporary socat proxy containers created by 'uc image push' with uncloud.managed 2026-01-28 17:40:15 +10:00
Pasha Sviderski 808bda67d4 chore: remove unused constants 2026-01-28 17:28:35 +10:00
Pasha Sviderski c91a964513 chore: make ssh+cli connections reuse one SSH connection via control socket. Fix image push 2026-01-28 16:49:08 +10:00
Pasha Sviderski e0a63a3f49 chore: typo 2026-01-27 11:32:18 +10:00
Pasha Sviderski 0b2e1bb7a2 chore: update newsletter form to substack 2026-01-26 21:10:40 +10:00
Pasha Sviderski 7c4f739d07 chore: specify machines to deploy website to 2026-01-26 18:03:46 +10:00
NickandGitHub 3bd9400051 feat: add support for ulimits in compose and container creation (#221) (#244)
* feat: add support for ulimits in compose and container creation ([#221](https://github.com/ipaddicting/uncloud/issues/221))
* refactor: move Ulimits to ContainerResources and use map, also removed GEMINI.md.
2026-01-20 16:17:54 +10:00
138 changed files with 6281 additions and 2099 deletions
+18 -11
View File
@@ -18,6 +18,8 @@ on:
- "test/**" - "test/**"
permissions: permissions:
contents: read contents: read
env:
MISE_VERSION: "2026.3.7"
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,10 +27,12 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Go - name: Install Mise
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with: with:
go-version: "1.25.1" version: ${{ env.MISE_VERSION }}
- name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock
- name: Install dependencies - name: Install dependencies
run: | run: |
@@ -44,7 +48,7 @@ jobs:
- name: Run tests - name: Run tests
run: | run: |
make ucind-image mise ucind:image
make test make test
timeout-minutes: 15 timeout-minutes: 15
@@ -61,15 +65,18 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Mise - name: Install Mise
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3 uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with: with:
version: "2025.9.6" version: ${{ env.MISE_VERSION }}
env:
GITHUB_TOKEN: ${{ github.token }} # TODO: figure out how to properly lock all platforms in the lockfile and remove this hack.
# `mise lock` (mise run mise:lock) doesn't generate checksums for some of the tools, e.g. protoc*
- name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock
- name: Generate Protobuf files - name: Generate Protobuf files
run: | run: |
make proto mise run proto
# check if the generated files are up to date # Check if the generated files are up to date.
git diff --exit-code || git diff --exit-code ||
(echo "Generated protobuf files are not up to date. Please run 'make proto' and commit the changes." && exit 1) (echo "Generated protobuf files are not up to date. Please run 'mise run proto' and commit the changes." && exit 1)
+9 -13
View File
@@ -13,6 +13,8 @@ on:
- "go.*" - "go.*"
permissions: permissions:
contents: read contents: read
env:
MISE_VERSION: "2026.3.7"
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -20,20 +22,14 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Go - name: Install Mise
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with: with:
go-version: "1.25.1" version: ${{ env.MISE_VERSION }}
- name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock
- name: golangci-lint - name: Lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 run: make lint
with:
version: v2.4.0
- name: Format code
run: |
make format
git diff --exit-code ||
(echo "Code is not formatted. Please run 'make format' and commit the changes." && exit 1)
timeout-minutes: 10 timeout-minutes: 10
+5
View File
@@ -8,7 +8,9 @@
*.so *.so
*.dylib *.dylib
/uncloud /uncloud
/uncloud-*
/uncloudd /uncloudd
/uncloudd-*
# OS X # OS X
.DS_Store .DS_Store
@@ -39,3 +41,6 @@ node_modules/
# VS Code # VS Code
.vscode .vscode
.devcontainer/
.zed/
-97
View File
@@ -1,97 +0,0 @@
[[tools."aqua:vektra/mockery"]]
version = "3.5.3"
backend = "aqua:vektra/mockery"
[tools."aqua:vektra/mockery".platforms.linux-x64]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
size = 5498859
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
[tools."aqua:vektra/mockery".platforms.macos-arm64]
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
size = 5238877
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz"
[[tools.go]]
version = "1.25.1"
backend = "core:go"
[tools.go.platforms.linux-x64]
checksum = "sha256:7716a0d940a0f6ae8e1f3b3f4f36299dc53e31b16840dbd171254312c41ca12e"
size = 59660846
url = "https://dl.google.com/go/go1.25.1.linux-amd64.tar.gz"
[tools.go.platforms.macos-arm64]
checksum = "sha256:68deebb214f39d542e518ebb0598a406ab1b5a22bba8ec9ade9f55fb4dd94a6c"
size = 57906702
url = "https://dl.google.com/go/go1.25.1.darwin-arm64.tar.gz"
[[tools.golangci-lint]]
version = "2.4.0"
backend = "aqua:golangci/golangci-lint"
[tools.golangci-lint.platforms.linux-x64]
checksum = "sha256:fae792524c04424c0ac369f5b8076f04b45cf29fc945a370e55d369a8dc11840"
size = 14126779
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-linux-amd64.tar.gz"
[tools.golangci-lint.platforms.macos-arm64]
checksum = "sha256:cd4dd53fa09b6646baff5fd22b8c64d91db02c21c7496df27992d75d34feec59"
size = 13542526
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-darwin-arm64.tar.gz"
[[tools.node]]
version = "22.20.0"
backend = "core:node"
[tools.node.platforms.linux-x64]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
size = 56645685
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
[tools.node.platforms.macos-arm64]
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
size = 49838299
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-darwin-arm64.tar.gz"
[[tools.protoc]]
version = "27.3"
backend = "aqua:protocolbuffers/protobuf/protoc"
[tools.protoc.platforms.linux-x64]
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
size = 3235873
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
[tools.protoc.platforms.macos-arm64]
checksum = "blake3:650c2494c003e9fddd6f4164a0a36e31da51ae7e1e5208da8cb560a4fe886fcd"
size = 2372763
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-aarch_64.zip"
[[tools.protoc-gen-go]]
version = "1.34.2"
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
[tools.protoc-gen-go.platforms.linux-x64]
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
size = 1848331
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
[tools.protoc-gen-go.platforms.macos-arm64]
checksum = "blake3:c237256a7f718ac4ccdc037ee30025a0b7c3f79300d7831ec59d76d782443fd5"
size = 1784028
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
[[tools.protoc-gen-go-grpc]]
version = "1.5.1"
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
[tools.protoc-gen-go-grpc.platforms.linux-x64]
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
size = 4859466
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
[tools.protoc-gen-go-grpc.platforms.macos-arm64]
checksum = "blake3:3c668207565d23ea2cf006d699207e3a804ebb929a534bd38a3b4ff4e941e00c"
size = 4565944
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz"
-14
View File
@@ -1,14 +0,0 @@
[settings]
experimental = true
[tools]
"aqua:vektra/mockery" = "3.5.3"
go = "1.25.1"
golangci-lint = "2.4.0"
node = "22.20.0"
protoc = "27.3"
protoc-gen-go = "1.34.2"
protoc-gen-go-grpc = "1.5.1"
[env]
_.file = ".env"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
#MISE description="Build and install uncloud binaries on dev machines, restart daemon if running"
#MISE depends=["build:uncloudd-amd64", "build:uncloudd-arm64"]
#MISE raw=true
#USAGE arg "<hosts>" var=#true var_min=1 help="Hosts in [user@]host[/arch] format, arch defaults to amd64"
for arg in ${usage_hosts}; do
if [[ "$arg" == */* ]]; then
host="${arg%/*}"
arch="${arg##*/}"
else
host="$arg"
arch="amd64"
fi
binary="uncloudd-linux-${arch}"
echo "==> Installing uncloudd on ${host} (${arch})..."
scp "$binary" "${host}:/tmp/"
ssh "$host" "sudo install /tmp/${binary} /usr/local/bin/uncloudd && rm /tmp/${binary} && sudo systemctl try-restart uncloud"
done
rm -f uncloudd-linux-amd64 uncloudd-linux-arm64
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
#MISE description="Stop uncloud daemon and wipe state on dev machines"
#USAGE arg "<hosts>" var=#true var_min=1 help="Hosts in [user@]host format"
for host in ${usage_hosts}; do
# Strip optional /arch suffix since reset doesn't need it.
host="${host%/*}"
echo "==> Resetting ${host}..."
ssh "$host" "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
done
echo "==> Done"
+38 -31
View File
@@ -1,10 +1,14 @@
# AI.md - Uncloud Project Guide # AGENTS.md - Uncloud Project Guide
This document provides comprehensive information about the Uncloud project for AI assistants to understand the codebase, architecture, and development practices. This document provides comprehensive information about the Uncloud project for AI assistants to understand the codebase,
architecture, and development practices.
## Project Overview ## Project Overview
**Uncloud** is a lightweight clustering and container orchestration tool that enables deployment and management of web applications across cloud VMs and bare metal servers. It creates a secure WireGuard mesh network between Docker hosts and provides automatic service discovery, load balancing, HTTPS ingress, and simple CLI commands for application management. **Uncloud** is a lightweight clustering and container orchestration tool that enables deployment and management of web
applications across cloud VMs and bare metal servers. It creates a secure WireGuard mesh network between Docker hosts
and provides automatic service discovery, load balancing, HTTPS ingress, and simple CLI commands for application
management.
### Key Characteristics ### Key Characteristics
@@ -68,29 +72,29 @@ This document provides comprehensive information about the Uncloud project for A
- **`cmd/`**: Contains main applications - **`cmd/`**: Contains main applications
- `uncloud/`: CLI tool with subcommands for machine, service, volume management - `uncloud/`: CLI tool with subcommands for machine, service, volume management
- `uncloudd/`: Daemon that runs on each machine - `uncloudd/`: Daemon that runs on each machine
- `ucind/`: Development cluster management for testing - `ucind/`: Development cluster management for testing
- **`internal/`**: Internal implementation packages - **`internal/`**: Internal implementation packages
- `cli/`: Command-line interface logic - `cli/`: Command-line interface logic
- `machine/`: Machine lifecycle and state management - `machine/`: Machine lifecycle and state management
- `daemon/`: Daemon implementation and gRPC services - `daemon/`: Daemon implementation and gRPC services
- `dns/`: Internal DNS server for service discovery - `dns/`: Internal DNS server for service discovery
- **`pkg/`**: Public API packages for external use - **`pkg/`**: Public API packages for external use
- `api/`: Core API types and definitions - `api/`: Core API types and definitions
- `client/`: Client libraries for interacting with Uncloud - `client/`: Client libraries for interacting with Uncloud
- **`experiment/`**: Experimental features and prototypes - **`experiment/`**: Experimental features and prototypes
- **`scripts/`**: Installation and utility scripts - **`scripts/`**: Installation and utility scripts
- **`test/`**: Test suites and test infrastructure - **`test/`**: Test suites and test infrastructure
- **`website/`**: Documentation website (Docusaurus) - **`website/`**: Documentation website (Docusaurus)
- `landing/`: Landing page - `landing/`: Landing page
- `docs/`: User documentation - `docs/`: User documentation
- **`misc/`**: Design documents and guides - **`misc/`**: Design documents and guides
@@ -100,25 +104,25 @@ This document provides comprehensive information about the Uncloud project for A
```go ```go
// Networking and orchestration // Networking and orchestration
github.com/docker/docker // Docker API client github.com/docker/docker // Docker API client
github.com/docker/compose/v2 // Docker Compose integration github.com/docker/compose/v2 // Docker Compose integration
golang.zx2c4.com/wireguard // WireGuard implementation golang.zx2c4.com/wireguard // WireGuard implementation
github.com/hashicorp/serf // Gossip protocol github.com/hashicorp/serf // Gossip protocol
// State management // State management
github.com/ipfs/go-ds-crdt // CRDT distributed storage github.com/ipfs/go-ds-crdt // CRDT distributed storage
github.com/dgraph-io/badger/v3 // Embedded database github.com/dgraph-io/badger/v3 // Embedded database
// Web proxy // Web proxy
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
// CLI and UX // CLI and UX
github.com/spf13/cobra // CLI framework github.com/spf13/cobra // CLI framework
github.com/charmbracelet/huh // Interactive forms github.com/charmbracelet/huh // Interactive forms
// gRPC and networking // gRPC and networking
google.golang.org/grpc // gRPC framework google.golang.org/grpc // gRPC framework
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
``` ```
## Development Workflow ## Development Workflow
@@ -189,9 +193,9 @@ uc context use <name> # Switch context
### Testing ### Testing
- Test files and locations - Test files and locations
- Unit tests alongside source files (`*_test.go`) - Unit tests alongside source files (`*_test.go`)
- Integration tests in `test/e2e/` - Integration tests in `test/e2e/`
- Test fixtures in `test/fixtures/` - Test fixtures in `test/fixtures/`
- Use table driven tests whenever possible - Use table driven tests whenever possible
### Dependencies ### Dependencies
@@ -225,8 +229,8 @@ uc context use <name> # Switch context
### Logs and Monitoring ### Logs and Monitoring
- Systemd services (getting logs via `journalctl -u SERVICE_NAME`) - Systemd services (getting logs via `journalctl -u SERVICE_NAME`)
- `uncloud` -- Uncloud daemon - `uncloud` -- Uncloud daemon
- `uncloud-corrosion` -- Corrosion process - `uncloud-corrosion` -- Corrosion process
- Machine daemon logs - Machine daemon logs
- Container logs via Docker - Container logs via Docker
@@ -247,7 +251,8 @@ uc context use <name> # Switch context
- `Makefile`: Build and development tasks - `Makefile`: Build and development tasks
- `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing) - `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing)
This document should help AI assistants understand the project structure, make informed suggestions, and contribute effectively to the Uncloud codebase. This document should help AI assistants understand the project structure, make informed suggestions, and contribute
effectively to the Uncloud codebase.
## Documentation ## Documentation
@@ -255,4 +260,6 @@ Instructions when generating documentation:
- Use conversational language write as if you were speaking to a friend. - Use conversational language write as if you were speaking to a friend.
- Keep sentences simple, optimize for clarity and understanding. - Keep sentences simple, optimize for clarity and understanding.
- Place the subject before the action whenever possible. Example: prefer “The function loads data” over “Data is loaded by the function.” - Do not use em dashes (—) or semicolons (;) in sentences. Instead, break complex sentences into simpler ones.
- Place the subject before the action whenever possible. Example: prefer "The function loads data" over "Data is loaded
by the function."
+1 -1
View File
@@ -1 +1 @@
./AI.md ./AGENTS.md
+5 -4
View File
@@ -1,6 +1,6 @@
ARG ALPINE_VERSION=3.20.3 ARG ALPINE_VERSION=3.23.3
FROM golang:1.25.1-alpine AS uncloudd FROM golang:1.26.1-alpine AS uncloudd
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@@ -39,10 +39,11 @@ CMD ["corrosion", "agent"]
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest" ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
ARG TARGETOS
ARG TARGETARCH
RUN apk --no-cache add crane RUN apk --no-cache add crane
RUN crane pull "${CORROSION_IMAGE}" /corrosion.tar RUN crane pull --platform ${TARGETOS}/${TARGETARCH} "${CORROSION_IMAGE}" /corrosion.tar
# Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker. # Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker.
FROM docker:27.3.1-dind AS ucind FROM docker:27.3.1-dind AS ucind
+154
View File
@@ -0,0 +1,154 @@
# Developing Uncloud
A short guide on setting up your local development environment.
## Prerequisites
- [mise](https://mise.jdx.dev/) (development environment setup tool)
- [Docker](https://docs.docker.com/get-docker/) (for running end-to-end tests)
## Setup
Uncloud uses `mise` to install and manage development tools and dependencies. It makes it easy to create reproducible
development environments on developer machines and CI.
Follow the [Installing Mise](https://mise.jdx.dev/installing-mise.html) guide to get it set up on your machine.
Mark the project as trusted and install all required tools (Go, `protoc`, `golangci-lint`, etc.) by running from the
project root:
```shell
mise trust
mise install
```
This reads [`mise.toml`](mise.toml) and installs the exact versions of tools specified there. They will be available in
your `PATH` when you are in the project directory, so you can run `go` or `golangci-lint` directly. The tools won't be
available outside the project, so they don't interfere with system packages.
## Building
Build the CLI:
```shell
go build -o uc ./cmd/uncloud
```
Or build and run the CLI with a single command:
```shell
go run ./cmd/uncloud --help
```
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
Windows:
```shell
GOOS=linux GOARCH=amd64 go build -o uncloudd ./cmd/uncloudd
```
## Testing
Run all tests (unit and e2e):
```shell
make test
```
### End-to-end tests
E2e tests run inside Docker using the [Uncloud-in-Docker](#uncloud-in-docker-ucind) image. Build it first:
```shell
mise ucind:image
```
⚠️ NOTE: You need to rebuild the `ucind` image every time you make changes to the daemon code if you want to test them
in e2e tests.
Then run the tests:
```shell
make test-e2e
```
If tests leave leftover containers, clean them up with:
```shell
make test-clean
```
### Manual testing on dev machines
After making changes to the daemon code, you may want to test them in your dev cluster. Build and install `uncloudd` on
one or multiple dev machines over SSH with:
```shell
mise run dev:install user@host1 user@host2/arm64
```
It will also restart the `uncloud.service` daemon on those machines to pick up the new version. The host format is
`[user@]host[/arch]`. The architecture of a machine defaults to `amd64` if not specified.
To stop the daemon and wipe all Uncloud data on the dev machines, run:
```shell
mise run dev:reset user@host1 user@host2
```
## Linting and formatting
Lint and format the code:
```shell
make lint-and-fix
```
## Code generation
Update the generated Go code for the machine gRPC API after modifying `.proto` files:
```shell
mise run proto
```
Regenerate mocks:
```shell
make mocks
```
## Uncloud in Docker (UCinD)
The [`ucind`](./cmd/ucind) CLI lets you run Uncloud clusters locally using Docker containers instead of real machines.
Each cluster machine runs as a Docker container connected to a shared Docker network.
Cluster machines use the `ghcr.io/psviderski/ucind:latest` Docker image by default. It may not always have the
latest changes to the `uncloudd` daemon code as it's not automatically rebuilt on CI yet.
You can rebuild it locally to pick up the latest changes, including your work in progress, and test them in a UCinD
cluster:
```shell
mise ucind:image
```
Create a cluster with 3 machines:
```shell
mise ucind cluster create -m 3
```
This also sets the new cluster as the active context, so you can interact with it using the `uc` CLI right away.
Remove the cluster when you're done:
```shell
mise ucind cluster rm
```
Sometimes crashed tests or interrupted runs can leave orphaned containers and networks behind. Clean them all up with:
```shell
mise ucind:cleanup
```
+7 -54
View File
@@ -1,55 +1,12 @@
# TODO: Makefile is deprecated, add new targets as mise tasks in mise.toml instead.
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
update-dev:
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
ssh spy@192.168.40.243 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
ssh spy@192.168.40.176 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && \
rm uncloudd-linux-amd64
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
# ssh ubuntu@152.67.101.197 sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && \
# rm uncloudd-linux-arm64
update-restart-dev:
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
ssh spy@192.168.40.243 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
ssh spy@192.168.40.176 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
rm uncloudd-linux-amd64
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
# ssh ubuntu@152.67.101.197 "sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
# rm uncloudd-linux-arm64
reset-dev:
ssh spy@192.168.40.243 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
ssh spy@192.168.40.176 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
ssh ubuntu@152.67.101.197 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
demo-reset:
rm -fv ~/.config/uncloud/config.yaml
ssh ubuntu@152.67.101.197 "AUTO_CONFIRM=true sudo -E uncloud-uninstall && docker rmi caddy:2.9.1"
ssh root@5.223.45.199 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
ssh spy@192.168.40.243 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
.PHONY: ucind-cluster .PHONY: ucind-cluster
ucind-cluster: ucind-cluster:
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3) go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
.PHONY: proto
proto:
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
--proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto
.PHONY: proto-mise
proto-mise:
mise exec -- protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
--proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto
.PHONY: corrosion-image .PHONY: corrosion-image
corrosion-image: corrosion-image:
docker build -t "$(CORROSION_IMAGE)" --target corrosion . docker build -t "$(CORROSION_IMAGE)" --target corrosion .
@@ -58,10 +15,6 @@ corrosion-image:
corrosion-multiarch-image-push: corrosion-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion . docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
.PHONY: ucind-image
ucind-image:
docker build -t "$(UCIND_IMAGE)" --target ucind .
.PHONY: ucind-multiarch-image-push .PHONY: ucind-multiarch-image-push
ucind-multiarch-image-push: ucind-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind . docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
@@ -73,23 +26,23 @@ mocks:
.PHONY: test .PHONY: test
test: test:
ifeq ($(TEST_NAME),) ifeq ($(TEST_NAME),)
go test -count=1 -v ./... go test -shuffle=on -count=1 -v ./...
else else
go test -race -count=1 -v -run ^$(TEST_NAME)$$ ./... go test -shuffle=on -race -count=1 -v -run ^$(TEST_NAME)$$ ./...
endif endif
.PHONY: test-e2e .PHONY: test-e2e
test-e2e: test-e2e:
go test -race -count=1 -v ./test/e2e go test -shuffle=on -race -count=1 -v ./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; \
+1 -1
View File
@@ -61,7 +61,7 @@ Here is a more advanced use case. Deploy a highly available web app with automat
on-premises in just a couple minutes. on-premises in just a couple minutes.
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau"> <a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" /> <img src="https://embed-ssl.wistia.com/deliveries/4fb71ea442905b81b1ad4e25fc02132e.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
</a> </a>
<br> <br>
+2
View File
@@ -98,6 +98,8 @@ func runBuild(ctx context.Context, uncli *cli.CLI, opts buildOptions) error {
return fmt.Errorf("load compose file(s): %w", err) return fmt.Errorf("load compose file(s): %w", err)
} }
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps) servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps)
if err != nil { if err != nil {
return fmt.Errorf("determine services to build: %w", err) return fmt.Errorf("determine services to build: %w", err)
+47 -15
View File
@@ -9,9 +9,11 @@ import (
"slices" "slices"
"strings" "strings"
"charm.land/lipgloss/v2"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
@@ -70,9 +72,11 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
if !errors.Is(err, api.ErrNotFound) { if !errors.Is(err, api.ErrNotFound) {
return fmt.Errorf("inspect caddy service: %w", err) return fmt.Errorf("inspect caddy service: %w", err)
} }
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName) fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(client.CaddyServiceName) +
tui.Faint.Render(" (not running)"))
} else { } else {
fmt.Printf("Service: %s (%s mode)\n", svc.Name, svc.Mode) fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(svc.Name) +
tui.Faint.Render(" ("+svc.Mode+" mode)"))
// Collect unique images of all containers in the running caddy service. // Collect unique images of all containers in the running caddy service.
images := make(map[string]struct{}, len(svc.Containers)) images := make(map[string]struct{}, len(svc.Containers))
@@ -82,15 +86,19 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
currentImages := slices.Collect(maps.Keys(images)) currentImages := slices.Collect(maps.Keys(images))
if len(currentImages) > 1 { if len(currentImages) > 1 {
commaSeparatedImages := strings.Join(currentImages, ", ") formattedImages := make([]string, len(currentImages))
fmt.Printf("Current images (multiple versions detected): %s\n", commaSeparatedImages) for i, img := range currentImages {
formattedImages[i] = tui.FormatImage(img, lipgloss.NewStyle())
}
fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") +
strings.Join(formattedImages, tui.Faint.Render(", ")))
} else { } else {
fmt.Printf("Current image: %s\n", currentImages[0]) fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], lipgloss.NewStyle()))
} }
} }
if opts.image != "" { if opts.image != "" {
fmt.Printf("Target image: %s\n", opts.image) fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green))
} }
fmt.Println() fmt.Println()
@@ -105,7 +113,8 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
} }
if opts.image == "" { if opts.image == "" {
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image) fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(d.Spec.Container.Image,
tui.Green) + tui.Faint.Render(" (latest stable)"))
} }
plan, err := d.Plan(ctx) plan, err := d.Plan(ctx)
@@ -124,18 +133,41 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
} }
} }
// Initialise a machine and container name resolver to properly format the plan output. fmt.Println()
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc) fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
if err != nil { fmt.Println()
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
deployTarget := ""
if directConn != "" {
deployTarget = directConn
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
fmt.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
deployTarget = contextName
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
fmt.Println()
} }
fmt.Println() fmt.Println(plan.Format())
fmt.Println("Deployment plan:")
fmt.Println(plan.Format(resolver)) summary := plan.FormatSummary()
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
fmt.Println() fmt.Println()
confirmed, err := cli.Confirm() title := "Proceed with deployment?"
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
// deploying to the wrong cluster.
if deployTarget != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with deployment to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
}
confirmed, err := tui.Confirm(title)
if err != nil { if err != nil {
return fmt.Errorf("confirm deployment: %w", err) return fmt.Errorf("confirm deployment: %w", err)
} }
+2 -2
View File
@@ -3,7 +3,7 @@ package context
import ( import (
"fmt" "fmt"
"github.com/charmbracelet/huh" "charm.land/huh/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/cli/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -60,7 +60,7 @@ func selectConnection(uncli *cli.CLI) error {
return fmt.Errorf("save config: %w", err) return fmt.Errorf("save config: %w", err)
} }
fmt.Printf("Default connection for context '%s' is now '%s'.\n", currentCtxName, selected) fmt.Printf("Default connection for context '%s' is now '%s'.\n", currentCtxName, selected.String())
return nil return nil
} }
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"maps" "maps"
"slices" "slices"
"github.com/charmbracelet/huh" "charm.land/huh/v2"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+53 -53
View File
@@ -4,13 +4,13 @@ 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"
@@ -20,12 +20,13 @@ import (
type deployOptions struct { type deployOptions struct {
cli.BuildServicesOptions cli.BuildServicesOptions
files []string files []string
profiles []string profiles []string
services []string services []string
noBuild bool noBuild bool
recreate bool recreate bool
yes bool skipHealth bool
yes bool
} }
// NewDeployCommand creates a new command to deploy services from a Compose file. // NewDeployCommand creates a new command to deploy services from a Compose file.
@@ -60,6 +61,10 @@ func NewDeployCommand() *cobra.Command {
"One or more Compose profiles to enable.") "One or more Compose profiles to enable.")
cmd.Flags().BoolVar(&opts.recreate, "recreate", false, cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
"Recreate containers even if their configuration and image haven't changed.") "Recreate containers even if their configuration and image haven't changed.")
cmd.Flags().BoolVar(&opts.skipHealth, "skip-health", false,
"Skip the monitoring period and health checks after starting new containers. Useful for faster emergency "+
"deployments.\n"+
"Warning: This may cause downtime if new containers fail to start properly.")
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+ "Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]") "e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
@@ -77,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)
@@ -148,9 +155,9 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
fmt.Println() fmt.Println()
} }
var strategy deploy.Strategy strategy := &deploy.RollingStrategy{
if opts.recreate { ForceRecreate: opts.recreate,
strategy = &deploy.RollingStrategy{ForceRecreate: true} SkipHealthMonitor: opts.skipHealth,
} }
composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy) composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy)
if err != nil { if err != nil {
@@ -162,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)
} }
@@ -190,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 deploy.SequenceOperation) error {
for _, op := range plan.Operations {
svcPlan, ok := op.(*deploy.Plan)
if !ok {
fmt.Println("- " + op.Format(nil))
continue
}
svc, err := cli.InspectService(ctx, svcPlan.ServiceID)
if err != nil && !errors.Is(err, api.ErrNotFound) {
return fmt.Errorf("inspect service: %w", err)
}
// Initialise a machine and container name resolver to properly format the service plan output.
resolver, err := cli.ServiceOperationNameResolver(ctx, svc)
if err != nil {
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
}
fmt.Printf("- Deploy service [name=%s]\n", svcPlan.ServiceName)
fmt.Println(indent(svcPlan.Format(resolver), " "))
}
return nil
}
func indent(text, prefix string) string {
lines := strings.Split(text, "\n")
for i, line := range lines {
lines[i] = prefix + line
}
return strings.Join(lines, "\n")
} }
+6 -4
View File
@@ -3,17 +3,19 @@ package image
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"slices" "slices"
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss/table" "charm.land/lipgloss/v2/table"
"github.com/charmbracelet/colorprofile"
"github.com/containerd/platforms" "github.com/containerd/platforms"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/muesli/termenv"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -222,7 +224,7 @@ func formatPlatforms(platforms []string) string {
Foreground(lipgloss.Color("0")). Foreground(lipgloss.Color("0")).
Background(lipgloss.Color("152")) Background(lipgloss.Color("152"))
// Use fancy pill borders only if the output is a terminal with color support. // Use fancy pill borders only if the output is a terminal with color support.
if lipgloss.ColorProfile() != termenv.Ascii { if colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ASCII {
platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true) platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true)
} }
+47 -22
View File
@@ -8,25 +8,28 @@ 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/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type addOptions struct { type addOptions struct {
name string name string
noCaddy bool noCaddy bool
noInstall bool noInstall bool
publicIP string publicIP string
sshKey string sshKey string
version string version string
yes bool wgEndpoints []string
yes bool
} }
func NewAddCommand() *cobra.Command { func NewAddCommand() *cobra.Command {
@@ -90,6 +93,14 @@ Connection methods:
&opts.version, "version", "latest", &opts.version, "version", "latest",
"Version of the Uncloud daemon to install on the machine.", "Version of the Uncloud daemon to install on the machine.",
) )
cmd.Flags().StringSliceVar(
&opts.wgEndpoints, "wg-endpoint", nil,
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
"Default port %d is used if omitted.\n", network.WireGuardPort)+
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
"If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
)
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+ "Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]") "Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
@@ -112,14 +123,24 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
publicIP = &ip publicIP = &ip
} }
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{ addOpts := cli.AddMachineOptions{
MachineName: opts.name, MachineName: opts.name,
PublicIP: publicIP, PublicIP: publicIP,
RemoteMachine: remoteMachine, RemoteMachine: remoteMachine,
SkipInstall: opts.noInstall, SkipInstall: opts.noInstall,
Version: opts.version, Version: opts.version,
AutoConfirm: opts.yes, AutoConfirm: opts.yes,
}) }
if len(opts.wgEndpoints) > 0 {
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
if err != nil {
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
}
addOpts.WireguardEndpoints = endpoints
}
clusterClient, machineClient, err := uncli.AddMachine(ctx, addOpts)
if err != nil { if err != nil {
return err return err
} }
@@ -134,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)
}). }).
@@ -175,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)
@@ -185,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)
} }
+35 -8
View File
@@ -7,8 +7,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/charmbracelet/huh/spinner" "charm.land/huh/v2/spinner"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy" "github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uncloud/dns" "github.com/psviderski/uncloud/cmd/uncloud/dns"
@@ -16,11 +16,13 @@ import (
"github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/cluster" "github.com/psviderski/uncloud/internal/machine/cluster"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type initOptions struct { type initOptions struct {
context string
dnsEndpoint string dnsEndpoint string
name string name string
network string network string
@@ -30,7 +32,7 @@ type initOptions struct {
publicIP string publicIP string
sshKey string sshKey string
version string version string
context string wgEndpoints []string
yes bool yes bool
} }
@@ -130,6 +132,14 @@ Connection methods:
&opts.version, "version", "latest", &opts.version, "version", "latest",
"Version of the Uncloud daemon to install on the machine.", "Version of the Uncloud daemon to install on the machine.",
) )
cmd.Flags().StringSliceVar(
&opts.wgEndpoints, "wg-endpoint", nil,
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
"Default port %d is used if omitted.\n", network.WireGuardPort)+
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
"If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
)
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+ "Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]") "Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
@@ -140,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)
@@ -161,7 +174,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
} }
publicIP = &ip publicIP = &ip
} }
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{ initOpts := cli.InitClusterOptions{
Context: opts.context, Context: opts.context,
MachineName: opts.name, MachineName: opts.name,
Network: netPrefix, Network: netPrefix,
@@ -170,7 +183,17 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
SkipInstall: opts.noInstall, SkipInstall: opts.noInstall,
Version: opts.version, Version: opts.version,
AutoConfirm: opts.yes, AutoConfirm: opts.yes,
}) }
if len(opts.wgEndpoints) > 0 {
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
if err != nil {
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
}
initOpts.WireguardEndpoints = endpoints
}
client, err := uncli.InitCluster(ctx, initOpts)
if err != nil { if err != nil {
return err return err
} }
@@ -182,8 +205,12 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
err = spinner.New(). err = spinner.New().
Title(" Waiting for the cluster to be ready..."). Title(" Waiting for the cluster to be ready...").
Type(spinner.MiniDot). Type(spinner.MiniDot).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))). WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
TitleStyle(lipgloss.NewStyle()). return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error { ActionWithErr(func(ctx context.Context) error {
return client.WaitClusterReady(ctx, 1*time.Minute) return client.WaitClusterReady(ctx, 1*time.Minute)
}). }).
+5 -4
View File
@@ -9,11 +9,12 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss/tree" "charm.land/lipgloss/v2/tree"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -118,7 +119,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
} }
if !opts.yes { if !opts.yes {
confirmed, err := cli.Confirm() confirmed, err := tui.Confirm("")
if err != nil { if err != nil {
return fmt.Errorf("confirm removal: %w", err) return fmt.Errorf("confirm removal: %w", err)
} }
@@ -154,7 +155,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
// Remove the connection to the machine from the uncloud config if it exists. // Remove the connection to the machine from the uncloud config if it exists.
if uncli.Config != nil { if uncli.Config != nil {
contextName := uncli.GetContextOverrideOrCurrent() contextName := uncli.ContextOverrideOrCurrent()
if context, ok := uncli.Config.Contexts[contextName]; ok { if context, ok := uncli.Config.Contexts[contextName]; ok {
for i, c := range context.Connections { for i, c := range context.Connections {
if c.MachineID == m.Id { if c.MachineID == m.Id {
-1
View File
@@ -17,7 +17,6 @@ func NewRootCommand() *cobra.Command {
NewRenameCommand(), NewRenameCommand(),
NewRmCommand(), NewRmCommand(),
NewUpdateCommand(), NewUpdateCommand(),
NewTokenCommand(),
) )
return cmd return cmd
} }
-39
View File
@@ -1,39 +0,0 @@
package machine
import (
"fmt"
"github.com/psviderski/uncloud/internal/daemon"
"github.com/psviderski/uncloud/internal/machine"
"github.com/spf13/cobra"
)
type tokenOptions struct {
dataDir string
}
func NewTokenCommand() *cobra.Command {
opts := tokenOptions{}
cmd := &cobra.Command{
Use: "token",
Short: "Print the local machine's token for adding it to a cluster.",
RunE: func(cmd *cobra.Command, args []string) error {
token, err := daemon.MachineToken(opts.dataDir)
if err != nil {
return fmt.Errorf("get machine token: %w", err)
}
tokenStr, err := token.String()
if err != nil {
return fmt.Errorf("encode machine token: %w", err)
}
fmt.Println(tokenStr)
return nil
},
}
cmd.Flags().StringVarP(&opts.dataDir, "data-dir", "d", machine.DefaultDataDir,
"Directory for storing persistent machine state.")
_ = cmd.MarkFlagDirname("data-dir")
return cmd
}
+63 -12
View File
@@ -4,29 +4,43 @@ import (
"context" "context"
"fmt" "fmt"
"net/netip" "net/netip"
"strings"
"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"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type updateOptions struct { type updateOptions struct {
name string name string
publicIP string publicIP string
wgEndpoints []string
} }
func NewUpdateCommand() *cobra.Command { func NewUpdateCommand() *cobra.Command {
opts := updateOptions{} opts := updateOptions{}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "update", Use: "update MACHINE [flags]",
Short: "Update machine configuration in the cluster.", Short: "Update machine configuration in the cluster.",
Long: `Update machine configuration in the cluster. Long: `Update machine configuration in the cluster.
This command allows setting various machine properties including: Change the name, public IP address, or WireGuard endpoints of an existing machine.
- Machine name (--name) At least one flag must be specified to perform an update.`,
- Public IP address (--public-ip) Example: ` # Rename a machine.
uc machine update machine1 --name web-server
At least one flag must be specified to perform an update operation.`, # Set the public IP address of a machine.
uc machine update machine1 --public-ip 203.0.113.10
# Remove the public IP address from a machine.
uc machine update machine1 --public-ip none
# Update WireGuard endpoints for a machine.
uc machine update machine1 --wg-endpoint 203.0.113.10 --wg-endpoint 192.168.1.5
# Update multiple properties at once.
uc machine update machine1 --name web-server --public-ip 203.0.113.10`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
@@ -40,16 +54,24 @@ At least one flag must be specified to perform an update operation.`,
) )
cmd.Flags().StringVar( cmd.Flags().StringVar(
&opts.publicIP, "public-ip", "", &opts.publicIP, "public-ip", "",
fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.", PublicIPNone), fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.",
PublicIPNone),
)
cmd.Flags().StringSliceVar(
&opts.wgEndpoints, "wg-endpoint", nil,
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
"Default port %d is used if omitted.\n", network.WireGuardPort)+
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.",
) )
return cmd return cmd
} }
func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts updateOptions, machineNameOrID string) error { func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts updateOptions, machineNameOrID string) error {
// Check if at least one flag was explicitly set // Check if at least one flag was explicitly set.
if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") { if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") && !cmd.Flags().Changed("wg-endpoint") {
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip)") return fmt.Errorf("at least one update flag must be specified (--name, --public-ip, --wg-endpoint)")
} }
client, err := uncli.ConnectCluster(ctx) client, err := uncli.ConnectCluster(ctx)
@@ -87,6 +109,19 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
} }
} }
// Parse and set endpoints if the flag was explicitly provided.
if cmd.Flags().Changed("wg-endpoint") {
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
if err != nil {
return err
}
if len(endpoints) == 0 {
return fmt.Errorf("at least one endpoint must be specified if --wg-endpoint flag is used")
}
req.Endpoints = endpoints
}
// Perform the update operation // Perform the update operation
updatedMachine, err := client.UpdateMachine(ctx, req) updatedMachine, err := client.UpdateMachine(ctx, req)
if err != nil { if err != nil {
@@ -113,8 +148,24 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
} }
changes = append(changes, fmt.Sprintf("public IP: %s -> %s", oldIP, newIP)) changes = append(changes, fmt.Sprintf("public IP: %s -> %s", oldIP, newIP))
} }
if cmd.Flags().Changed("wg-endpoint") {
formatEndpoints := func(eps []*pb.IPPort) string {
if len(eps) == 0 {
return "none"
}
parts := make([]string, len(eps))
for i, ep := range eps {
ap, _ := ep.ToAddrPort()
parts[i] = ap.String()
}
return strings.Join(parts, ", ")
}
oldEndpoints := formatEndpoints(machine.Machine.Network.Endpoints)
newEndpoints := formatEndpoints(updatedMachine.Network.Endpoints)
changes = append(changes, fmt.Sprintf("endpoints: %s -> %s", oldEndpoints, newEndpoints))
}
fmt.Printf("Machine %q (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id) fmt.Printf("Machine '%s' (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id)
for _, change := range changes { for _, change := range changes {
fmt.Printf(" %s\n", change) fmt.Printf(" %s\n", change)
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/netip" "net/netip"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/cmd/uncloud/caddy" "github.com/psviderski/uncloud/cmd/uncloud/caddy"
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context" cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
"github.com/psviderski/uncloud/cmd/uncloud/dns" "github.com/psviderski/uncloud/cmd/uncloud/dns"
+11 -5
View File
@@ -6,11 +6,12 @@ import (
"sort" "sort"
"time" "time"
"github.com/charmbracelet/huh/spinner" "charm.land/huh/v2/spinner"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss/table" "charm.land/lipgloss/v2/table"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -85,7 +86,12 @@ func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
err = spinner.New(). err = spinner.New().
Title(" Collecting container info..."). Title(" Collecting container info...").
Type(spinner.MiniDot). Type(spinner.MiniDot).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))). WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error { ActionWithErr(func(ctx context.Context) error {
containers, err = collectContainers(ctx, clusterClient) containers, err = collectContainers(ctx, clusterClient)
return err return err
@@ -226,7 +232,7 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
} }
if msc.Metadata != nil && msc.Metadata.Error != "" { if msc.Metadata != nil && msc.Metadata.Error != "" {
client.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName, tui.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
msc.Metadata.Error)) msc.Metadata.Error))
continue continue
} }
+36 -36
View File
@@ -39,25 +39,25 @@ func (m *mockClusterClient) ListMachines(ctx context.Context, in *emptypb.Empty,
func TestCollectContainers_NilMetadata(t *testing.T) { func TestCollectContainers_NilMetadata(t *testing.T) {
// Setup container data // Setup container data
containerData := map[string]interface{}{ containerData := map[string]any{
"Id": "container1", "Id": "container1",
"Name": "test-container", "Name": "test-container",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "test-image", "Image": "test-image",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON, err := json.Marshal(containerData) containerJSON, err := json.Marshal(containerData)
require.NoError(t, err) require.NoError(t, err)
serviceSpecJSON, err := json.Marshal(map[string]interface{}{}) serviceSpecJSON, err := json.Marshal(map[string]any{})
require.NoError(t, err) require.NoError(t, err)
// Setup mocks // Setup mocks
@@ -120,28 +120,28 @@ func TestCollectContainers_NilMetadata_MultipleMachines_Error(t *testing.T) {
// If we have multiple machines but receive nil metadata, it should return an error as it is ambiguous // If we have multiple machines but receive nil metadata, it should return an error as it is ambiguous
// Setup container data // Setup container data
containerData1 := map[string]interface{}{ containerData1 := map[string]any{
"Id": "container1", "Id": "container1",
} }
containerJSON1, _ := json.Marshal(containerData1) containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]interface{}{ containerData2 := map[string]any{
"Id": "container2", "Id": "container2",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "test-image", "Image": "test-image",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON2, _ := json.Marshal(containerData2) containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{}) serviceSpecJSON, _ := json.Marshal(map[string]any{})
// Setup mocks // Setup mocks
mockDocker := &mockDockerClient{ mockDocker := &mockDockerClient{
@@ -210,41 +210,41 @@ func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
// Verify correct mapping of containers to machines when metadata is present // Verify correct mapping of containers to machines when metadata is present
// Setup container data // Setup container data
containerData1 := map[string]interface{}{ containerData1 := map[string]any{
"Id": "container1", "Id": "container1",
"Name": "container-1", "Name": "container-1",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "image-1", "Image": "image-1",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON1, _ := json.Marshal(containerData1) containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]interface{}{ containerData2 := map[string]any{
"Id": "container2", "Id": "container2",
"Name": "container-2", "Name": "container-2",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "image-2", "Image": "image-2",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON2, _ := json.Marshal(containerData2) containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{}) serviceSpecJSON, _ := json.Marshal(map[string]any{})
// Setup mocks // Setup mocks
mockDocker := &mockDockerClient{ mockDocker := &mockDockerClient{
@@ -321,22 +321,22 @@ func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) { func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
// Case: 1 msc with nil metadata but no machines at all // Case: 1 msc with nil metadata but no machines at all
containerData := map[string]interface{}{ containerData := map[string]any{
"Id": "container1", "Id": "container1",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "test-image", "Image": "test-image",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON, _ := json.Marshal(containerData) containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{}) serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{ mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{ listResp: &pb.ListServiceContainersResponse{
@@ -378,22 +378,22 @@ func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) { func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) {
// Case: msc with metadata that is not in the IP-to-name mapping // Case: msc with metadata that is not in the IP-to-name mapping
containerData := map[string]interface{}{ containerData := map[string]any{
"Id": "container1", "Id": "container1",
"Config": map[string]interface{}{ "Config": map[string]any{
"Image": "test-image", "Image": "test-image",
}, },
"State": map[string]interface{}{ "State": map[string]any{
"Status": "running", "Status": "running",
"StartedAt": "2023-01-01T12:00:00Z", "StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z", "FinishedAt": "0001-01-01T00:00:00Z",
}, },
"NetworkSettings": map[string]interface{}{ "NetworkSettings": map[string]any{
"Networks": map[string]interface{}{}, "Networks": map[string]any{},
}, },
} }
containerJSON, _ := json.Marshal(containerData) containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{}) serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{ mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{ listResp: &pb.ListServiceContainersResponse{
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -83,7 +84,7 @@ If the service has multiple replicas and no container ID is specified, the comma
func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error { func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error {
// Disable TTY allocation if not connected to a terminal // Disable TTY allocation if not connected to a terminal
if !cli.IsStdoutTerminal() { if !tui.IsStdoutTerminal() {
opts.noTty = true opts.noTty = true
} }
+42 -15
View File
@@ -4,16 +4,18 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"image/color"
"os" "os"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/docker/docker/pkg/stringid" "github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose" "github.com/psviderski/uncloud/pkg/client/compose"
@@ -99,15 +101,20 @@ If no services are specified, streams logs from all services defined in the Comp
func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logsOptions) error { func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logsOptions) error {
// If no services specified, try to load them from the Compose file(s). // If no services specified, try to load them from the Compose file(s).
fromCompose := false
if len(serviceNames) == 0 { if len(serviceNames) == 0 {
fromCompose = true
project, err := compose.LoadProject(ctx, opts.files) project, err := compose.LoadProject(ctx, opts.files)
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 {
return errors.New("no services found in compose file(s)") return errors.New("no services found in Compose file(s)")
} }
} }
@@ -135,20 +142,40 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
Machines: cli.ExpandCommaSeparatedValues(opts.machines), Machines: cli.ExpandCommaSeparatedValues(opts.machines),
} }
// Collect log streams from all services. // Collect log streams from all services. When service names come from a Compose file,
// skip the ones that are not found in the cluster (they may have been removed or not deployed yet).
machineIDsSet := mapset.NewSet[string]() machineIDsSet := mapset.NewSet[string]()
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames)) svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames))
var foundServices, notFoundServices []string
for _, serviceName := range serviceNames { for _, serviceName := range serviceNames {
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts) svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts)
if err != nil { if err != nil {
if errors.Is(err, api.ErrNotFound) && fromCompose {
notFoundServices = append(notFoundServices, serviceName)
continue
}
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err) return fmt.Errorf("stream logs for service '%s': %w", serviceName, err)
} }
svcStreams = append(svcStreams, ch) svcStreams = append(svcStreams, ch)
foundServices = append(foundServices, serviceName)
machineIDs := svc.MachineIDs() machineIDs := svc.MachineIDs()
machineIDsSet.Append(machineIDs...) machineIDsSet.Append(machineIDs...)
} }
if fromCompose {
if len(foundServices) == 0 {
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
strings.Join(opts.files, ", "))
}
serviceNames = foundServices
for _, name := range notFoundServices {
tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
}
}
var stream <-chan api.ServiceLogEntry var stream <-chan api.ServiceLogEntry
if len(serviceNames) == 1 { if len(serviceNames) == 1 {
stream = svcStreams[0] stream = svcStreams[0]
@@ -183,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.
+47 -9
View File
@@ -9,6 +9,7 @@ import (
dockeropts "github.com/docker/cli/opts" dockeropts "github.com/docker/cli/opts"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/daemon/names" "github.com/docker/docker/daemon/names"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
@@ -27,11 +28,13 @@ type runOptions struct {
machines []string machines []string
memory dockeropts.MemBytes memory dockeropts.MemBytes
mode string mode string
shmSize dockeropts.MemBytes
name string name string
privileged bool privileged bool
publish []string publish []string
pull string pull string
replicas uint replicas uint
ulimits []string
user string user string
volumes []string volumes []string
} }
@@ -76,8 +79,12 @@ func NewRunCommand(groupID string) *cobra.Command {
"Placement constraint by machine names, limiting which machines the service can run on. Can be specified "+ "Placement constraint by machine names, limiting which machines the service can run on. Can be specified "+
"multiple times or as a comma-separated list of machine names. (default is any suitable machine)") "multiple times or as a comma-separated list of machine names. (default is any suitable machine)")
cmd.Flags().VarP(&opts.memory, "memory", "", cmd.Flags().VarP(&opts.memory, "memory", "",
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix "+ "Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
"(b, k, m, g). Default unit is bytes if no suffix specified.\n"+ "Default unit is bytes if no suffix specified.\n"+
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
cmd.Flags().VarP(&opts.shmSize, "shm-size", "",
"Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer\n"+
"with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)") "Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
cmd.Flags().StringVarP(&opts.name, "name", "n", "", cmd.Flags().StringVarP(&opts.name, "name", "n", "",
"Assign a name to the service. A random name is generated if not specified.") "Assign a name to the service. A random name is generated if not specified.")
@@ -102,6 +109,12 @@ func NewRunCommand(groupID string) *cobra.Command {
cmd.Flags().StringVarP(&opts.user, "user", "u", "", cmd.Flags().StringVarP(&opts.user, "user", "u", "",
"User name or UID and optionally group name or GID used for running the command inside service containers.\n"+ "User name or UID and optionally group name or GID used for running the command inside service containers.\n"+
"Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.") "Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.")
cmd.Flags().StringSliceVar(&opts.ulimits, "ulimit", nil,
"Set resource limits for service containers. Can be specified multiple times.\n"+
"Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.\n"+
"Examples:\n"+
" --ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files\n"+
" --ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes")
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil, cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
"Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+ "Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+
"the volume is located. Can be specified multiple times.\n"+ "the volume is located. Can be specified multiple times.\n"+
@@ -194,13 +207,18 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
ports[i] = port ports[i] = port
} }
volumes, mounts, err := parseVolumeFlags(opts.volumes) placement := api.Placement{
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
}
ulimits, err := parseUlimits(opts.ulimits)
if err != nil { if err != nil {
return spec, err return spec, err
} }
placement := api.Placement{ volumes, mounts, err := parseVolumeFlags(opts.volumes)
Machines: cli.ExpandCommaSeparatedValues(opts.machines), if err != nil {
return spec, err
} }
spec = api.ServiceSpec{ spec = api.ServiceSpec{
@@ -211,8 +229,10 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
Privileged: opts.privileged, Privileged: opts.privileged,
PullPolicy: opts.pull, PullPolicy: opts.pull,
Resources: api.ContainerResources{ Resources: api.ContainerResources{
CPU: opts.cpu.Value(), CPU: opts.cpu.Value(),
Memory: opts.memory.Value(), Memory: opts.memory.Value(),
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
+56 -35
View File
@@ -2,12 +2,16 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os"
"strconv" "strconv"
"strings"
"github.com/charmbracelet/huh" "charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -15,6 +19,7 @@ import (
type scaleOptions struct { type scaleOptions struct {
service string service string
replicas uint replicas uint
yes bool
} }
func NewScaleCommand(groupID string) *cobra.Command { func NewScaleCommand(groupID string) *cobra.Command {
@@ -22,9 +27,11 @@ func NewScaleCommand(groupID string) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "scale SERVICE REPLICAS", Use: "scale SERVICE REPLICAS",
Short: "Scale a replicated service by changing the number of replicas.", Short: "Scale a replicated service by changing the number of replicas.",
Long: "Scale a replicated service by changing the number of replicas. Scaling down requires confirmation.", Long: "Scale a replicated service by changing the number of replicas.",
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0] opts.service = args[0]
@@ -39,6 +46,10 @@ func NewScaleCommand(groupID string) *cobra.Command {
GroupID: groupID, GroupID: groupID,
} }
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm scaling plan. Should be explicitly set when running non-interactively,\n"+
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
return cmd return cmd
} }
@@ -71,7 +82,8 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
currentReplicas := uint(len(svc.Containers)) currentReplicas := uint(len(svc.Containers))
if currentReplicas == opts.replicas { if currentReplicas == opts.replicas {
fmt.Printf("Service '%s' already has %d replicas. No changes required.\n", svc.Name, currentReplicas) fmt.Printf("Service %s already has %s replicas. No changes required.\n",
tui.NameStyle.Render(svc.Name), tui.Bold.Render(fmt.Sprintf("%d", currentReplicas)))
return nil return nil
} }
@@ -86,23 +98,51 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
} }
if len(plan.Operations) == 0 { if len(plan.Operations) == 0 {
fmt.Printf("Service '%s' is already scaled to %d replicas.\n", svc.Name, opts.replicas) fmt.Printf("Service %s is already scaled to %d replicas.\n", tui.NameStyle.Render(svc.Name), opts.replicas)
return nil return nil
} }
if opts.replicas < currentReplicas { fmt.Println(tui.Bold.Underline(true).Render("Scaling plan"))
// Initialise a machine and container name resolver to properly format the plan output. fmt.Println()
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
if err != nil { directConn := uncli.DirectConnection()
return fmt.Errorf("create machine and container name resolver for service operations: %w", err) contextName := uncli.ContextOverrideOrCurrent()
deployTarget := ""
if directConn != "" {
deployTarget = directConn
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
fmt.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
deployTarget = contextName
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
fmt.Println()
}
fmt.Println(plan.Format())
summary := plan.FormatSummary()
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
fmt.Println()
// Ask for confirmation unless auto-confirmed with --yes.
if !opts.yes {
if !tui.IsStdinTerminal() {
return errors.New("cannot ask to confirm scaling plan in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
} }
fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas) title := "Proceed with scaling?"
fmt.Println(plan.Format(resolver)) // Include the direct connection or context name in the confirmation prompt to avoid accidentally
fmt.Println() // scaling on the wrong cluster.
if deployTarget != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with scaling on " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
}
// Ask for confirmation before scaling down as it may cause data loss. confirmed, err := tui.Confirm(title)
confirmed, err := confirm()
if err != nil { if err != nil {
return fmt.Errorf("confirm scaling: %w", err) return fmt.Errorf("confirm scaling: %w", err)
} }
@@ -112,7 +152,8 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
} }
} }
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas) title := fmt.Sprintf("Scaling service %s (%d → %d replicas)",
tui.NameStyle.Render(svc.Name), currentReplicas, opts.replicas)
err = progress.RunWithTitle(ctx, func(ctx context.Context) error { err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if _, err = deployment.Run(ctx); err != nil { if _, err = deployment.Run(ctx); err != nil {
return fmt.Errorf("deploy service: %w", err) return fmt.Errorf("deploy service: %w", err)
@@ -125,23 +166,3 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
return nil return nil
} }
func confirm() (bool, error) {
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(
"Do you want to continue?",
).
Affirmative("Yes!").
Negative("No").
Value(&confirmed),
),
).WithAccessible(true)
if err := form.Run(); err != nil {
return false, err
}
return confirmed, nil
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"slices" "slices"
"strings" "strings"
"github.com/charmbracelet/huh" "charm.land/huh/v2"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -79,7 +80,7 @@ func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOpti
} }
fmt.Println() fmt.Println()
confirmed, err := cli.Confirm() confirmed, err := tui.Confirm("")
if err != nil { if err != nil {
return fmt.Errorf("confirm removal: %w", err) return fmt.Errorf("confirm removal: %w", err)
} }
+12 -12
View File
@@ -17,57 +17,57 @@ func newIPFSLogger(l *slog.Logger) *ipfsLogger {
return &ipfsLogger{log: *l} return &ipfsLogger{log: *l}
} }
func (l *ipfsLogger) Debug(args ...interface{}) { func (l *ipfsLogger) Debug(args ...any) {
l.log.Debug(fmt.Sprint(args...)) l.log.Debug(fmt.Sprint(args...))
} }
func (l *ipfsLogger) Debugf(format string, args ...interface{}) { func (l *ipfsLogger) Debugf(format string, args ...any) {
l.log.Debug(fmt.Sprintf(format, args...)) l.log.Debug(fmt.Sprintf(format, args...))
} }
func (l *ipfsLogger) Error(args ...interface{}) { func (l *ipfsLogger) Error(args ...any) {
l.log.Error(fmt.Sprint(args...)) l.log.Error(fmt.Sprint(args...))
} }
func (l *ipfsLogger) Errorf(format string, args ...interface{}) { func (l *ipfsLogger) Errorf(format string, args ...any) {
l.log.Error(fmt.Sprintf(format, args...)) l.log.Error(fmt.Sprintf(format, args...))
} }
func (l *ipfsLogger) Fatal(args ...interface{}) { func (l *ipfsLogger) Fatal(args ...any) {
l.log.Error(fmt.Sprint(args...)) l.log.Error(fmt.Sprint(args...))
os.Exit(1) os.Exit(1)
} }
func (l *ipfsLogger) Fatalf(format string, args ...interface{}) { func (l *ipfsLogger) Fatalf(format string, args ...any) {
l.log.Error(fmt.Sprintf(format, args...)) l.log.Error(fmt.Sprintf(format, args...))
os.Exit(1) os.Exit(1)
} }
func (l *ipfsLogger) Info(args ...interface{}) { func (l *ipfsLogger) Info(args ...any) {
l.log.Info(fmt.Sprint(args...)) l.log.Info(fmt.Sprint(args...))
} }
func (l *ipfsLogger) Infof(format string, args ...interface{}) { func (l *ipfsLogger) Infof(format string, args ...any) {
l.log.Info(fmt.Sprintf(format, args...)) l.log.Info(fmt.Sprintf(format, args...))
} }
func (l *ipfsLogger) Panic(args ...interface{}) { func (l *ipfsLogger) Panic(args ...any) {
msg := fmt.Sprint(args...) msg := fmt.Sprint(args...)
l.log.Error(msg) l.log.Error(msg)
panic(msg) panic(msg)
} }
func (l *ipfsLogger) Panicf(format string, args ...interface{}) { func (l *ipfsLogger) Panicf(format string, args ...any) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
l.log.Error(msg) l.log.Error(msg)
panic(msg) panic(msg)
} }
func (l *ipfsLogger) Warn(args ...interface{}) { func (l *ipfsLogger) Warn(args ...any) {
l.log.Warn(fmt.Sprint(args...)) l.log.Warn(fmt.Sprint(args...))
} }
func (l *ipfsLogger) Warnf(format string, args ...interface{}) { func (l *ipfsLogger) Warnf(format string, args ...any) {
l.log.Warn(fmt.Sprintf(format, args...)) l.log.Warn(fmt.Sprintf(format, args...))
} }
+20 -21
View File
@@ -1,19 +1,19 @@
module github.com/psviderski/uncloud module github.com/psviderski/uncloud
go 1.25 go 1.26
require ( require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/huh/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.1
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver v1.5.0
github.com/Masterminds/squirrel v1.5.4 github.com/Masterminds/squirrel v1.5.4
github.com/alecthomas/chroma/v2 v2.20.0 github.com/alecthomas/chroma/v2 v2.20.0
github.com/caddyserver/caddy/v2 v2.8.4 github.com/caddyserver/caddy/v2 v2.8.4
github.com/cenkalti/backoff/v4 v4.3.0 github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/colorprofile v0.4.2
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b
github.com/charmbracelet/lipgloss v1.1.0
github.com/compose-spec/compose-go/v2 v2.9.0 github.com/compose-spec/compose-go/v2 v2.9.0
github.com/containerd/errdefs v1.0.0 github.com/containerd/errdefs v1.0.0
github.com/containerd/platforms v1.0.0-rc.1 github.com/containerd/platforms v1.0.0-rc.1
@@ -43,7 +43,6 @@ require (
github.com/miekg/dns v1.1.65 github.com/miekg/dns v1.1.65
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/moby/term v0.5.2 github.com/moby/term v0.5.2
github.com/muesli/termenv v0.16.0
github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/psviderski/unregistry v0.4.1 github.com/psviderski/unregistry v0.4.1
@@ -57,8 +56,8 @@ require (
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0 golang.org/x/net v0.43.0
golang.org/x/sync v0.17.0 golang.org/x/sync v0.19.0
golang.org/x/sys v0.36.0 golang.org/x/sys v0.42.0
golang.org/x/term v0.34.0 golang.org/x/term v0.34.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
@@ -66,6 +65,7 @@ require (
google.golang.org/grpc v1.74.2 google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.9 google.golang.org/protobuf v1.36.9
modernc.org/sqlite v1.36.3 modernc.org/sqlite v1.36.3
tags.cncf.io/container-device-interface v1.0.1
) )
require ( require (
@@ -102,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
@@ -149,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
@@ -238,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
@@ -277,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
@@ -415,5 +415,4 @@ require (
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect
tags.cncf.io/container-device-interface v1.0.1 // indirect
) )
+46 -44
View File
@@ -1,3 +1,11 @@
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/huh/v2 v2.0.1 h1:9vhBjlIDuikdPKH+qnoG++GERVxqY0Lkv14xW57lj98=
charm.land/huh/v2 v2.0.1/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ= cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc= cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
@@ -119,10 +127,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudr
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -157,8 +163,8 @@ github.com/caddyserver/certmagic v0.21.4 h1:e7VobB8rffHv8ZZpSiZtEwnLDHUwLVYLWzWS
github.com/caddyserver/certmagic v0.21.4/go.mod h1:swUXjQ1T9ZtMv95qj7/InJvWLXURU85r+CfG0T+ZbDE= github.com/caddyserver/certmagic v0.21.4/go.mod h1:swUXjQ1T9ZtMv95qj7/InJvWLXURU85r+CfG0T+ZbDE=
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -168,28 +174,30 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b h1:oefmPctgff7OB4cDz3Ndp+ewwrh7i+fwth70qvZCieI= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8= github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
@@ -202,6 +210,10 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e85keuznYcH5rqI438v41pKcBl4ZxQ= github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e85keuznYcH5rqI438v41pKcBl4ZxQ=
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
@@ -341,8 +353,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
@@ -766,8 +776,8 @@ github.com/libp2p/go-yamux/v4 v4.0.1 h1:FfDR4S1wj6Bw2Pqbc8Uz7pCxeRBPbwsBbEdfwiCy
github.com/libp2p/go-yamux/v4 v4.0.1/go.mod h1:NWjl8ZTLOGlozrXSOZ/HlfG++39iKNnM5wwmtQP1YB4= github.com/libp2p/go-yamux/v4 v4.0.1/go.mod h1:NWjl8ZTLOGlozrXSOZ/HlfG++39iKNnM5wwmtQP1YB4=
github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw=
github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
@@ -795,10 +805,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@@ -895,12 +903,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
@@ -1059,7 +1063,6 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -1418,8 +1421,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1452,7 +1455,6 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1471,8 +1473,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
composetypes "github.com/compose-spec/compose-go/v2/types" composetypes "github.com/compose-spec/compose-go/v2/types"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
+54 -26
View File
@@ -71,12 +71,22 @@ func (cli *CLI) SetCurrentContext(name string) error {
return cli.Config.Save() return cli.Config.Save()
} }
func (cli *CLI) GetContextOverrideOrCurrent() string { func (cli *CLI) ContextOverrideOrCurrent() string {
contextName := cli.contextOverride if cli.contextOverride != "" {
if contextName == "" { return cli.contextOverride
contextName = cli.Config.CurrentContext
} }
return contextName if cli.Config != nil {
return cli.Config.CurrentContext
}
return ""
}
// DirectConnection returns the connection string if --connect or UNCLOUD_CONNECT was specified.
func (cli *CLI) DirectConnection() string {
if cli.conn != nil {
return cli.conn.String()
}
return ""
} }
// ConnectCluster connects to a cluster using the context override or the current context if not specified. // ConnectCluster connects to a cluster using the context override or the current context if not specified.
@@ -155,14 +165,15 @@ func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, opts ConnectOptio
} }
type InitClusterOptions struct { type InitClusterOptions struct {
Context string Context string
MachineName string MachineName string
Network netip.Prefix Network netip.Prefix
PublicIP *netip.Addr PublicIP *netip.Addr
RemoteMachine *RemoteMachine RemoteMachine *RemoteMachine
SkipInstall bool SkipInstall bool
Version string Version string
AutoConfirm bool AutoConfirm bool
WireguardEndpoints []*pb.IPPort
} }
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster. // InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
@@ -220,8 +231,9 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
} }
req := &pb.InitClusterRequest{ req := &pb.InitClusterRequest{
MachineName: opts.MachineName, MachineName: opts.MachineName,
Network: pb.NewIPPrefix(opts.Network), Network: pb.NewIPPrefix(opts.Network),
WireguardEndpoints: opts.WireguardEndpoints,
} }
if opts.PublicIP != nil { if opts.PublicIP != nil {
if opts.PublicIP.IsValid() { if opts.PublicIP.IsValid() {
@@ -298,12 +310,13 @@ func (cli *CLI) newContextName(name string) (string, error) {
} }
type AddMachineOptions struct { type AddMachineOptions struct {
MachineName string MachineName string
PublicIP *netip.Addr PublicIP *netip.Addr
RemoteMachine *RemoteMachine RemoteMachine *RemoteMachine
SkipInstall bool SkipInstall bool
Version string Version string
AutoConfirm bool AutoConfirm bool
WireguardEndpoints []*pb.IPPort
} }
// AddMachine provisions a remote machine and adds it to the cluster. It returns a cluster client and a machine client. // AddMachine provisions a remote machine and adds it to the cluster. It returns a cluster client and a machine client.
@@ -311,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)
@@ -380,10 +393,16 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
return nil, nil, fmt.Errorf("parse remote machine token: %w", err) return nil, nil, fmt.Errorf("parse remote machine token: %w", err)
} }
// Register the machine in the cluster using its public key and endpoints from the token. // Register the machine in the cluster using its public key and WireGuard endpoints from the token,
endpoints := make([]*pb.IPPort, len(token.Endpoints)) // or use the explicitly provided WireGuard endpoints if any.
for i, addrPort := range token.Endpoints { var endpoints []*pb.IPPort
endpoints[i] = pb.NewIPPort(addrPort) if len(opts.WireguardEndpoints) > 0 {
endpoints = opts.WireguardEndpoints
} else {
endpoints = make([]*pb.IPPort, len(token.Endpoints))
for i, addrPort := range token.Endpoints {
endpoints[i] = pb.NewIPPort(addrPort)
}
} }
addReq := &pb.AddMachineRequest{ addReq := &pb.AddMachineRequest{
Name: opts.MachineName, Name: opts.MachineName,
@@ -552,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)
+13 -17
View File
@@ -11,11 +11,6 @@ import (
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
) )
const (
DefaultSSHUser = "root"
DefaultSSHPort = 22
)
type MachineConnection struct { type MachineConnection struct {
SSH SSHDestination `yaml:"ssh,omitempty"` SSH SSHDestination `yaml:"ssh,omitempty"`
SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"` SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"`
@@ -30,7 +25,7 @@ type MachineConnection struct {
MachineID string `yaml:"machine_id,omitempty"` MachineID string `yaml:"machine_id,omitempty"`
} }
func (c MachineConnection) String() string { func (c *MachineConnection) String() string {
if c.SSH != "" { if c.SSH != "" {
return "ssh://" + string(c.SSH) return "ssh://" + string(c.SSH)
} else if c.SSHCLI != "" { } else if c.SSHCLI != "" {
@@ -69,36 +64,37 @@ func (c *MachineConnection) Validate() error {
} }
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port". // SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
// The default user "root" and port 22 can be omitted. // Empty user or port components are omitted.
type SSHDestination string type SSHDestination string
// NewSSHDestination constructs an SSHDestination from user, host, and port components.
// If user is empty, it is omitted.
// If port is 0, it is omitted.
func NewSSHDestination(user, host string, port int) SSHDestination { func NewSSHDestination(user, host string, port int) SSHDestination {
dst := host dst := host
if port != 0 && port != DefaultSSHPort { if port != 0 {
dst = net.JoinHostPort(host, strconv.Itoa(port)) dst = net.JoinHostPort(host, strconv.Itoa(port))
} }
if user == "" { if user != "" {
user = DefaultSSHUser dst = fmt.Sprintf("%s@%s", user, dst)
} }
dst = user + "@" + dst
return SSHDestination(dst) return SSHDestination(dst)
} }
// Parse parses the SSH destination string into user, host, and port components.
// If user is not specified, it returns an empty string.
// If port is not specified, it returns 0.
func (d SSHDestination) Parse() (user string, host string, port int, err error) { func (d SSHDestination) Parse() (user string, host string, port int, err error) {
host = string(d) host = string(d)
if strings.Contains(host, "@") { if strings.Contains(host, "@") {
user, host, _ = strings.Cut(host, "@") user, host, _ = strings.Cut(host, "@")
} }
if user == "" {
user = DefaultSSHUser
}
h, p, sErr := net.SplitHostPort(host) h, p, sErr := net.SplitHostPort(host)
if sErr == nil { if sErr == nil {
host = h host = h
port, err = strconv.Atoi(p) port, err = strconv.Atoi(p)
} }
if port == 0 {
port = DefaultSSHPort
}
return return
} }
+12 -11
View File
@@ -7,10 +7,11 @@ import (
"os" "os"
"time" "time"
"github.com/charmbracelet/bubbles/spinner" "charm.land/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/fs" "github.com/psviderski/uncloud/internal/fs"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/connector" "github.com/psviderski/uncloud/pkg/client/connector"
@@ -33,7 +34,7 @@ func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts Con
// If the stdout is not a terminal, it falls back to simple progress logs to stderr. // If the stdout is not a terminal, it falls back to simple progress logs to stderr.
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) { func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
// If stdout is not a terminal, fall back to simple progress logs. // If stdout is not a terminal, fall back to simple progress logs.
if !IsStdoutTerminal() { if !tui.IsStdoutTerminal() {
fmt.Fprintln(os.Stderr, "Connecting to", conn.String()) fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
cli, err := connectCluster(ctx, conn) cli, err := connectCluster(ctx, conn)
if err != nil { if err != nil {
@@ -125,7 +126,7 @@ type showSpinnerMsg struct{}
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel { func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
s := spinner.New() s := spinner.New()
s.Spinner = spinner.MiniDot s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // the same yellow as in compose progress s.Style = lipgloss.NewStyle().Foreground(lipgloss.Yellow) // the same yellow as in compose progress
return connectModel{ return connectModel{
ctx: ctx, ctx: ctx,
@@ -187,8 +188,8 @@ func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner, cmd = m.spinner.Update(msg) m.spinner, cmd = m.spinner.Update(msg)
return m, cmd return m, cmd
case tea.KeyMsg: case tea.KeyPressMsg:
if msg.Type == tea.KeyCtrlC { if msg.String() == "ctrl+c" {
m.result.err = fmt.Errorf("connection cancelled") m.result.err = fmt.Errorf("connection cancelled")
m.done = true m.done = true
return m, tea.Quit return m, tea.Quit
@@ -198,15 +199,15 @@ func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m connectModel) View() string { func (m connectModel) View() tea.View {
// Don't show anything if done or spinner not yet visible. // Don't show anything if done or spinner not yet visible.
if m.done || !m.showSpinner { if m.done || !m.showSpinner {
return "" return tea.NewView("")
} }
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153")) style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
return fmt.Sprintf("%s %s\n", return tea.NewView(fmt.Sprintf("%s %s\n",
m.spinner.View(), m.spinner.View(),
fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())), fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())),
) ))
} }
+24 -1
View File
@@ -1,10 +1,14 @@
package cli package cli
import ( import (
"fmt"
"log" "log"
"net/netip"
"os" "os"
"strings" "strings"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -16,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)
} }
@@ -34,3 +38,22 @@ func BindEnvToFlag(cmd *cobra.Command, flagName, envVar string) {
} }
} }
} }
// ParseWireGuardEndpoints parses a list of endpoint strings into a list of IPPort protobuf messages. Each value can
// be an IP address, IP:PORT, IPv6, or [IPv6]:PORT. If the port is omitted, the default WireGuard port is used.
func ParseWireGuardEndpoints(values []string) ([]*pb.IPPort, error) {
endpoints := make([]*pb.IPPort, 0, len(values))
for _, v := range values {
ap, err := netip.ParseAddrPort(v)
if err != nil {
// Try parsing as a bare IP address and use the default WireGuard port.
addr, addrErr := netip.ParseAddr(v)
if addrErr != nil {
return nil, fmt.Errorf("invalid endpoint '%s': must be IP, IPv6, IP:PORT, or [IPv6]:PORT", v)
}
ap = netip.AddrPortFrom(addr, network.WireGuardPort)
}
endpoints = append(endpoints, pb.NewIPPort(ap))
}
return endpoints, nil
}
+3 -2
View File
@@ -8,8 +8,9 @@ import (
"strings" "strings"
"time" "time"
"charm.land/huh/v2"
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
"github.com/charmbracelet/huh" "github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/sshexec" "github.com/psviderski/uncloud/internal/sshexec"
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
@@ -88,7 +89,7 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string
} }
func promptResetMachine() error { func promptResetMachine() error {
if !IsStdinTerminal() { if !tui.IsStdinTerminal() {
return errors.New("the remote machine is already initialised as a cluster member; " + return errors.New("the remote machine is already initialised as a cluster member; " +
"cannot ask to confirm reset in non-interactive mode, " + "cannot ask to confirm reset in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm") "use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
-38
View File
@@ -1,38 +0,0 @@
package cli
import (
"os"
"github.com/charmbracelet/huh"
"golang.org/x/term"
)
func Confirm() (bool, error) {
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(
"Do you want to continue?",
).
Affirmative("Yes!").
Negative("No").
Value(&confirmed),
),
).WithAccessible(true)
if err := form.Run(); err != nil {
return false, err
}
return confirmed, nil
}
// IsStdinTerminal checks if the standard input is a terminal (TTY).
func IsStdinTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
// IsStdoutTerminal checks if the standard output is a terminal (TTY).
func IsStdoutTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
+11
View File
@@ -0,0 +1,11 @@
package tui
import (
"fmt"
"os"
)
func PrintWarning(msg string) {
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
fmt.Fprintln(os.Stderr, styledMsg)
}
+53
View File
@@ -0,0 +1,53 @@
package tui
import (
"os"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"golang.org/x/term"
)
// Confirm shows a confirmation prompt with a yellow-styled title.
// If title is empty, it defaults to "Do you want to continue?".
func Confirm(title string) (bool, error) {
if title == "" {
title = "Do you want to continue?"
}
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(title).
Affirmative("Yes!").
Negative("No").
Value(&confirmed),
),
).WithTheme(ThemeConfirm()).
WithAccessible(true)
if err := form.Run(); err != nil {
return false, err
}
return confirmed, nil
}
// ThemeConfirm returns a huh theme with a bold yellow title style for the confirmation prompt.
func ThemeConfirm() huh.Theme {
return huh.ThemeFunc(func(isDark bool) *huh.Styles {
t := huh.ThemeBase(isDark)
t.Focused.Title = t.Focused.Title.Foreground(lipgloss.Yellow).Bold(true)
return t
})
}
// IsStdinTerminal checks if the standard input is a terminal (TTY).
func IsStdinTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
// IsStdoutTerminal checks if the standard output is a terminal (TTY).
func IsStdoutTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
+36
View File
@@ -0,0 +1,36 @@
package tui
import (
"charm.land/lipgloss/v2"
"github.com/distribution/reference"
)
var (
Faint = lipgloss.NewStyle().Faint(true)
Red = lipgloss.NewStyle().Foreground(lipgloss.Red)
Green = lipgloss.NewStyle().Foreground(lipgloss.Green)
Yellow = lipgloss.NewStyle().Foreground(lipgloss.Yellow)
Bold = lipgloss.NewStyle().Bold(true)
BoldRed = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Red)
BoldGreen = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Green)
BoldYellow = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Yellow)
NameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("152"))
)
// FormatImage renders an image reference with the given style, using a faint colon separator for tagged images.
// Returns the styled original string if parsing fails.
func FormatImage(image string, style lipgloss.Style) string {
ref, err := reference.ParseDockerRef(image)
if err != nil {
return style.Render(image)
}
if tagged, ok := ref.(reference.NamedTagged); ok {
return style.Render(reference.FamiliarName(ref)) +
Faint.Render(":") +
style.Render(tagged.Tag())
}
return style.Render(reference.FamiliarString(ref))
}
-42
View File
@@ -1,42 +0,0 @@
package daemon
import (
"errors"
"fmt"
"net/netip"
"os"
"github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/machine/network"
)
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
// TODO: ideally, this should be an RPC call to the daemon API to ensure the config is created and up-to-date.
func MachineToken(dataDir string) (machine.Token, error) {
state, err := machine.ParseState(machine.StatePath(dataDir))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return machine.Token{}, fmt.Errorf("load machine config (is uncloudd daemon running?): %w", err)
}
return machine.Token{}, fmt.Errorf("load machine config: %w", err)
}
if len(state.Network.PublicKey) == 0 {
return machine.Token{}, errors.New("public key is not set in machine config")
}
ips, err := network.ListRoutableIPs()
if err != nil {
return machine.Token{}, fmt.Errorf("list routable addresses: %w", err)
}
publicIP, err := network.GetPublicIP()
// Ignore the error if failed to get the public IP using API services.
if err == nil {
ips = append(ips, publicIP)
}
endpoints := make([]netip.AddrPort, len(ips))
for i, ip := range ips {
endpoints[i] = netip.AddrPortFrom(ip, network.WireGuardPort)
}
return machine.NewToken(state.Network.PublicKey, publicIP, endpoints), nil
}
+1 -1
View File
@@ -26,7 +26,7 @@ type RecordResponse struct {
type AuthErrorResponse struct { type AuthErrorResponse struct {
Status int `json:"status,omitempty"` Status int `json:"status,omitempty"`
Message string `json:"msg,omitempty"` Message string `json:"msg,omitempty"`
Data authErrorData `json:"data,omitempty"` Data authErrorData `json:"data"`
} }
type authErrorData struct { type authErrorData struct {
+5 -3
View File
@@ -36,10 +36,12 @@ func InspectGitState(dir string) (GitState, error) {
return state, nil return state, nil
} }
// Get the current commit SHA. // Get the current commit SHA. An initialised but empty repo has no HEAD yet,
sha, err := gitCommand(dir, "rev-parse", "HEAD") // so treat it as a non-repo so callers can fall back to non-git logic.
sha, err := gitCommand(dir, "rev-parse", "--verify", "HEAD")
if err != nil { if err != nil {
return state, fmt.Errorf("get current commit SHA: %w", err) state.IsRepo = false
return state, nil
} }
state.SHA = strings.TrimSpace(sha) state.SHA = strings.TrimSpace(sha)
+16
View File
@@ -46,6 +46,22 @@ func TestInspectGitState_NotARepo(t *testing.T) {
assert.False(t, state.IsDirty) assert.False(t, state.IsDirty)
} }
func TestInspectGitState_EmptyRepo(t *testing.T) {
// Create a temporary git repo with no commits.
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
// An empty repo should be treated as a non-repo so that callers fall back to non-git logic.
assert.False(t, state.IsRepo)
assert.Empty(t, state.SHA)
assert.Empty(t, state.ShortSHA(7))
assert.True(t, state.Date.IsZero())
assert.False(t, state.IsDirty)
}
func TestInspectGitState_CleanRepo(t *testing.T) { func TestInspectGitState_CleanRepo(t *testing.T) {
// Create a temporary git repo. // Create a temporary git repo.
tmpDir := t.TempDir() tmpDir := t.TempDir()
+169 -155
View File
@@ -233,6 +233,8 @@ type InitClusterRequest struct {
// *InitClusterRequest_PublicIp // *InitClusterRequest_PublicIp
// *InitClusterRequest_PublicIpAuto // *InitClusterRequest_PublicIpAuto
PublicIpConfig isInitClusterRequest_PublicIpConfig `protobuf_oneof:"public_ip_config"` PublicIpConfig isInitClusterRequest_PublicIpConfig `protobuf_oneof:"public_ip_config"`
// Optional WireGuard endpoints other machines will use to connect to this machine instead of auto-discovered ones.
WireguardEndpoints []*IPPort `protobuf:"bytes,5,rep,name=wireguard_endpoints,json=wireguardEndpoints,proto3" json:"wireguard_endpoints,omitempty"`
} }
func (x *InitClusterRequest) Reset() { func (x *InitClusterRequest) Reset() {
@@ -302,6 +304,13 @@ func (x *InitClusterRequest) GetPublicIpAuto() bool {
return false return false
} }
func (x *InitClusterRequest) GetWireguardEndpoints() []*IPPort {
if x != nil {
return x.WireguardEndpoints
}
return nil
}
type isInitClusterRequest_PublicIpConfig interface { type isInitClusterRequest_PublicIpConfig interface {
isInitClusterRequest_PublicIpConfig() isInitClusterRequest_PublicIpConfig()
} }
@@ -1041,7 +1050,7 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73,
0x66, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x61, 0x74, 0x69, 0x66, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x61, 0x74, 0x69,
0x73, 0x66, 0x69, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x73, 0x66, 0x69, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xc3, 0x01, 0x0a, 0x12, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x81, 0x02, 0x0a, 0x12,
0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d, 0x73, 0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d,
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65,
@@ -1052,130 +1061,134 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x48, 0x00, 0x52, 0x08, 0x70, 0x75, 0x62, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x48, 0x00, 0x52, 0x08, 0x70, 0x75, 0x62,
0x6c, 0x69, 0x63, 0x49, 0x70, 0x12, 0x26, 0x0a, 0x0e, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x12, 0x26, 0x0a, 0x0e, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f,
0x69, 0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x69, 0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52,
0x0c, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x42, 0x12, 0x0a, 0x0c, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x12, 0x3c, 0x0a,
0x10, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x13, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f,
0x67, 0x22, 0x41, 0x0a, 0x13, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61,
0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x64, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x42, 0x12, 0x0a, 0x10, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22,
0x41, 0x0a, 0x13, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e,
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61,
0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69,
0x6e, 0x65, 0x22, 0xaa, 0x01, 0x0a, 0x12, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74,
0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63,
0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69,
0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61,
0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x37, 0x0a, 0x0e, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x5f, 0x6d,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52,
0x0d, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x2f,
0x0a, 0x14, 0x6d, 0x69, 0x6e, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x64, 0x62, 0x5f, 0x76,
0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x6d, 0x69,
0x6e, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22,
0x49, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e,
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x08, 0x6d, 0x61, 0x63,
0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73,
0x52, 0x08, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x22, 0x91, 0x01, 0x0a, 0x0e, 0x4d,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x29, 0x0a,
0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08,
0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68,
0x69, 0x6e, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63,
0x68, 0x69, 0x6e, 0x65, 0x22, 0xaa, 0x01, 0x0a, 0x12, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x64, 0x62,
0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x25,
0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x37, 0x0a, 0x0e, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
0x5f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x0e, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x65, 0x74, 0x52, 0x65,
0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xc3, 0x01, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
0x6f, 0x52, 0x0d, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,
0x12, 0x2f, 0x0a, 0x14, 0x6d, 0x69, 0x6e, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x64, 0x62, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20,
0x6d, 0x69, 0x6e, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x36, 0x0a, 0x0a, 0x63, 0x6f, 0x6e,
0x6e, 0x22, 0x49, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e,
0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x08, 0x6d, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x6e, 0x74,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x73, 0x1a, 0x48, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1d,
0x6c, 0x73, 0x52, 0x08, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x22, 0x91, 0x01, 0x0a, 0x0a, 0x0a, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x0e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x12, 0x1c, 0x0a,
0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c,
0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x22, 0x27, 0x0a, 0x15, 0x49,
0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71,
0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x52, 0x02, 0x69, 0x64, 0x22, 0x40, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26,
0x64, 0x62, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x52, 0x0e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x0c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x07, 0x73,
0x22, 0x25, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0xb2, 0x01, 0x0a, 0x1f, 0x49, 0x6e, 0x73, 0x70, 0x65,
0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x0e, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x65, 0x74,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xc3, 0x01, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76,
0x69, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18,
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x36, 0x0a, 0x0a, 0x63,
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x43, 0x6f,
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e,
0x65, 0x72, 0x73, 0x1a, 0x48, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x12,
0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01,
0x28, 0x0c, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x22, 0x27, 0x0a,
0x15, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x40, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63,
0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x26, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0xb2, 0x01, 0x0a, 0x1f, 0x49, 0x6e, 0x73,
0x70, 0x65, 0x63, 0x74, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74,
0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e,
0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x4e,
0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65,
0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b,
0x65, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72,
0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50,
0x6f, 0x72, 0x74, 0x12, 0x28, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61,
0x72, 0x64, 0x50, 0x65, 0x65, 0x72, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x22, 0x83, 0x02,
0x0a, 0x0d, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x50, 0x65, 0x65, 0x72, 0x12,
0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x1a,
0x0a, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
0x52, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x13, 0x6c, 0x61,
0x73, 0x74, 0x5f, 0x68, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x5f, 0x74, 0x69, 0x6d,
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
0x61, 0x6d, 0x70, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61,
0x6b, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76,
0x65, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72,
0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x74,
0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x74, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x74, 0x42, 0x79, 0x74,
0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x69, 0x70,
0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64,
0x49, 0x70, 0x73, 0x32, 0xe3, 0x04, 0x0a, 0x07, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12,
0x4d, 0x0a, 0x12, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69,
0x73, 0x69, 0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1f, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75,
0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40,
0x0a, 0x0b, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x17, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69,
0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x3e, 0x0a, 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12,
0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65,
0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
0x12, 0x33, 0x0a, 0x05, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74,
0x79, 0x1a, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x33, 0x0a, 0x07, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x45, 0x0a, 0x0e, 0x49, 0x6e,
0x73, 0x70, 0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x16, 0x2e, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45,
0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65,
0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x57, 0x0a, 0x17, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x57, 0x69, 0x72, 0x65,
0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x16, 0x2e, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45,
0x6d, 0x70, 0x74, 0x79, 0x1a, 0x24, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65,
0x63, 0x74, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x63, 0x74, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f,
0x72, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x05, 0x52, 0x65, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e,
0x73, 0x65, 0x74, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x52, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d,
0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79,
0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18,
0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72,
0x74, 0x12, 0x28, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64,
0x50, 0x65, 0x65, 0x72, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x22, 0x83, 0x02, 0x0a, 0x0d,
0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x50, 0x65, 0x65, 0x72, 0x12, 0x1d, 0x0a,
0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08,
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x13, 0x6c, 0x61, 0x73, 0x74,
0x5f, 0x68, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
0x70, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65,
0x54, 0x69, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x5f,
0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72, 0x65, 0x63,
0x65, 0x69, 0x76, 0x65, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x72, 0x61,
0x6e, 0x73, 0x6d, 0x69, 0x74, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x74, 0x42, 0x79, 0x74, 0x65, 0x73,
0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x69, 0x70, 0x73, 0x18,
0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70,
0x73, 0x32, 0xe3, 0x04, 0x0a, 0x07, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x4d, 0x0a,
0x12, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73, 0x69,
0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1f, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73,
0x69, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b,
0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43,
0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e,
0x0a, 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x17, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x49, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x33,
0x0a, 0x0e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x0a, 0x05, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a,
0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x6e, 0x73, 0x65, 0x12, 0x33, 0x0a, 0x07, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x12, 0x16,
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63,
0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x45, 0x0a, 0x0e, 0x49, 0x6e, 0x73, 0x70,
0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f,
0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70,
0x74, 0x79, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x57, 0x0a, 0x17, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75,
0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f,
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70,
0x74, 0x79, 0x1a, 0x24, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x05, 0x52, 0x65, 0x73, 0x65,
0x74, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x49, 0x0a, 0x0e,
0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x1a,
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76,
0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69,
0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75,
0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69,
0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61,
0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62,
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
} }
var ( var (
@@ -1223,39 +1236,40 @@ var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
18, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort 18, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort
17, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix 17, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix
16, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP 16, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP
0, // 7: api.InitClusterResponse.machine:type_name -> api.MachineInfo 18, // 7: api.InitClusterRequest.wireguard_endpoints:type_name -> api.IPPort
0, // 8: api.JoinClusterRequest.machine:type_name -> api.MachineInfo 0, // 8: api.InitClusterResponse.machine:type_name -> api.MachineInfo
0, // 9: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo 0, // 9: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
7, // 10: api.InspectMachineResponse.machines:type_name -> api.MachineDetails 0, // 10: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
19, // 11: api.MachineDetails.metadata:type_name -> api.Metadata 7, // 11: api.InspectMachineResponse.machines:type_name -> api.MachineDetails
0, // 12: api.MachineDetails.machine:type_name -> api.MachineInfo 19, // 12: api.MachineDetails.metadata:type_name -> api.Metadata
15, // 13: api.Service.containers:type_name -> api.Service.Container 0, // 13: api.MachineDetails.machine:type_name -> api.MachineInfo
10, // 14: api.InspectServiceResponse.service:type_name -> api.Service 15, // 14: api.Service.containers:type_name -> api.Service.Container
14, // 15: api.InspectWireGuardNetworkResponse.peers:type_name -> api.WireGuardPeer 10, // 15: api.InspectServiceResponse.service:type_name -> api.Service
20, // 16: api.WireGuardPeer.last_handshake_time:type_name -> google.protobuf.Timestamp 14, // 16: api.InspectWireGuardNetworkResponse.peers:type_name -> api.WireGuardPeer
21, // 17: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty 20, // 17: api.WireGuardPeer.last_handshake_time:type_name -> google.protobuf.Timestamp
3, // 18: api.Machine.InitCluster:input_type -> api.InitClusterRequest 21, // 18: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
5, // 19: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest 3, // 19: api.Machine.InitCluster:input_type -> api.InitClusterRequest
21, // 20: api.Machine.Token:input_type -> google.protobuf.Empty 5, // 20: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest
21, // 21: api.Machine.Inspect:input_type -> google.protobuf.Empty 21, // 21: api.Machine.Token:input_type -> google.protobuf.Empty
21, // 22: api.Machine.InspectMachine:input_type -> google.protobuf.Empty 21, // 22: api.Machine.Inspect:input_type -> google.protobuf.Empty
21, // 23: api.Machine.InspectWireGuardNetwork:input_type -> google.protobuf.Empty 21, // 23: api.Machine.InspectMachine:input_type -> google.protobuf.Empty
9, // 24: api.Machine.Reset:input_type -> api.ResetRequest 21, // 24: api.Machine.InspectWireGuardNetwork:input_type -> google.protobuf.Empty
11, // 25: api.Machine.InspectService:input_type -> api.InspectServiceRequest 9, // 25: api.Machine.Reset:input_type -> api.ResetRequest
2, // 26: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse 11, // 26: api.Machine.InspectService:input_type -> api.InspectServiceRequest
4, // 27: api.Machine.InitCluster:output_type -> api.InitClusterResponse 2, // 27: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
21, // 28: api.Machine.JoinCluster:output_type -> google.protobuf.Empty 4, // 28: api.Machine.InitCluster:output_type -> api.InitClusterResponse
8, // 29: api.Machine.Token:output_type -> api.TokenResponse 21, // 29: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
0, // 30: api.Machine.Inspect:output_type -> api.MachineInfo 8, // 30: api.Machine.Token:output_type -> api.TokenResponse
6, // 31: api.Machine.InspectMachine:output_type -> api.InspectMachineResponse 0, // 31: api.Machine.Inspect:output_type -> api.MachineInfo
13, // 32: api.Machine.InspectWireGuardNetwork:output_type -> api.InspectWireGuardNetworkResponse 6, // 32: api.Machine.InspectMachine:output_type -> api.InspectMachineResponse
21, // 33: api.Machine.Reset:output_type -> google.protobuf.Empty 13, // 33: api.Machine.InspectWireGuardNetwork:output_type -> api.InspectWireGuardNetworkResponse
12, // 34: api.Machine.InspectService:output_type -> api.InspectServiceResponse 21, // 34: api.Machine.Reset:output_type -> google.protobuf.Empty
26, // [26:35] is the sub-list for method output_type 12, // 35: api.Machine.InspectService:output_type -> api.InspectServiceResponse
17, // [17:26] is the sub-list for method input_type 27, // [27:36] is the sub-list for method output_type
17, // [17:17] is the sub-list for extension type_name 18, // [18:27] is the sub-list for method input_type
17, // [17:17] is the sub-list for extension extendee 18, // [18:18] is the sub-list for extension type_name
0, // [0:17] is the sub-list for field type_name 18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
} }
func init() { file_internal_machine_api_pb_machine_proto_init() } func init() { file_internal_machine_api_pb_machine_proto_init() }
+3
View File
@@ -55,6 +55,9 @@ message InitClusterRequest {
IP public_ip = 3; IP public_ip = 3;
bool public_ip_auto = 4; bool public_ip_auto = 4;
} }
// Optional WireGuard endpoints other machines will use to connect to this machine instead of auto-discovered ones.
repeated IPPort wireguard_endpoints = 5;
} }
message InitClusterResponse { message InitClusterResponse {
+1 -1
View File
@@ -107,7 +107,7 @@ func (d *Director) remoteBackend(addr string) (*RemoteBackend, error) {
// FlushRemoteBackends closes all remote backend connections and removes them from the cache. // FlushRemoteBackends closes all remote backend connections and removes them from the cache.
func (d *Director) FlushRemoteBackends() { func (d *Director) FlushRemoteBackends() {
d.remoteBackends.Range(func(key, value interface{}) bool { d.remoteBackends.Range(func(key, value any) bool {
backend, ok := value.(*RemoteBackend) backend, ok := value.(*RemoteBackend)
if !ok { if !ok {
return true return true
+4 -3
View File
@@ -226,12 +226,13 @@ func (g *CaddyfileGenerator) Generate(
// Append error summary as comment if there were any invalid configs. // Append error summary as comment if there were any invalid configs.
if len(configErrors) > 0 { if len(configErrors) > 0 {
errorsComment := "# Skipped invalid user-defined configs:\n" var errorsComment strings.Builder
errorsComment.WriteString("# Skipped invalid user-defined configs:\n")
for _, e := range configErrors { for _, e := range configErrors {
errorsComment += fmt.Sprintf("# - %s\n", e) errorsComment.WriteString(fmt.Sprintf("# - %s\n", e))
} }
caddyfile += "\n" + errorsComment caddyfile += "\n" + errorsComment.String()
} }
return caddyfileHeader + "\n" + caddyfile, nil return caddyfileHeader + "\n" + caddyfile, nil
+49 -3
View File
@@ -376,8 +376,6 @@ func (cc *clusterController) waitStoreSync(ctx context.Context) {
} }
if version >= minVersion { if version >= minVersion {
slog.Info("Cluster store completed the initial sync.", "version", version, "min_version", minVersion)
// Clear MinStoreDBVersion so next restart doesn't wait for sync. // Clear MinStoreDBVersion so next restart doesn't wait for sync.
cc.state.mu.Lock() cc.state.mu.Lock()
cc.state.MinStoreDBVersion = 0 cc.state.MinStoreDBVersion = 0
@@ -386,6 +384,25 @@ func (cc *clusterController) waitStoreSync(ctx context.Context) {
} }
cc.state.mu.Unlock() cc.state.mu.Unlock()
// Wait for all known missing changes to be synced before returning.
// TODO: reevaluate if this is necessary after migrating to the latest Corrosion version:
// https://github.com/psviderski/uncloud/issues/172
// This works on the best effort basis as the missing changes may not be yet known when we start
// checking it after reaching the minimum version.
// Reaching the minimum version doesn't guarantee that the store is actually synced to
// the state we observed on the source node. db_version is a machine-local Lamport clock.
// When changes are received from a remote machine, the local db_version is set to
// max(local_db_version, incoming_db_version) + 1 for each applied transaction. This means
// the new machine's db_version can jump well past the source machine's db_version on the very
// first batch of replicated changes, without having received all changes from all machines
// in the cluster.
cc.waitKnownMissingChanges(ctx)
if ver, verErr := cc.store.DBVersion(ctx); verErr == nil {
version = ver
}
slog.Info("Cluster store completed the initial sync.", "version", version, "min_version", minVersion)
return return
} }
@@ -399,6 +416,34 @@ func (cc *clusterController) waitStoreSync(ctx context.Context) {
} }
} }
// waitKnownMissingChanges polls the store until all known missing changes have been synced.
func (cc *clusterController) waitKnownMissingChanges(ctx context.Context) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
changes, err := cc.store.KnownMissingChanges(ctx)
if err != nil {
slog.Error("Failed to get known missing changes from the cluster store, skipping check.",
"err", err)
return
}
if len(changes) == 0 {
slog.Debug("All known missing changes have been synced to the cluster store.")
return
}
slog.Debug("Waiting for known missing changes to be synced to the cluster store.", "remaining",
len(changes))
}
}
}
// syncDockerContainers watches local Docker containers and syncs them to the cluster store. // syncDockerContainers watches local Docker containers and syncs them to the cluster store.
// TODO: move this to the Docker controller. // TODO: move this to the Docker controller.
func (cc *clusterController) syncDockerContainers(ctx context.Context) error { func (cc *clusterController) syncDockerContainers(ctx context.Context) error {
@@ -458,7 +503,8 @@ func (cc *clusterController) handleMachineChanges(ctx context.Context) error {
// The machine store may be empty when a machine first joins the cluster, before store synchronization // The machine store may be empty when a machine first joins the cluster, before store synchronization
// completes. Skip configuration now and apply it when the store changes are received. // completes. Skip configuration now and apply it when the store changes are received.
// TODO: remove this check after releasing 0.17.0 and assuming cluster machines wait for store sync on join. // TODO: remove this check after ensuring the store is actually synced to the latest known state at this point.
// See TODO in waitStoreSync.
if len(machines) > 0 { if len(machines) > 0 {
slog.Info("Reconfiguring network peers with the current machines.", "machines", len(machines)) slog.Info("Reconfiguring network peers with the current machines.", "machines", len(machines))
if err = cc.configurePeers(machines); err != nil { if err = cc.configurePeers(machines); err != nil {
+1 -1
View File
@@ -259,7 +259,7 @@ func (c *Cluster) UpdateMachine(ctx context.Context, req *pb.UpdateMachineReques
updatedMachine.PublicIp = req.PublicIp updatedMachine.PublicIp = req.PublicIp
} }
} }
if req.Endpoints != nil { if len(req.Endpoints) > 0 {
updatedMachine.Network.Endpoints = req.Endpoints updatedMachine.Network.Endpoints = req.Endpoints
} }
+55
View File
@@ -33,6 +33,7 @@ import (
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy" "github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/docker/go-units"
"github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote"
@@ -566,6 +567,22 @@ func (s *Server) CreateServiceContainer(
if spec.Mode == "" { if spec.Mode == "" {
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
} }
if hc := spec.Container.Healthcheck; hc != nil {
if hc.Disable {
config.Healthcheck = &container.HealthConfig{
Test: []string{"NONE"},
}
} else {
config.Healthcheck = &container.HealthConfig{
Test: hc.Test,
Interval: hc.Interval,
Timeout: hc.Timeout,
StartPeriod: hc.StartPeriod,
StartInterval: hc.StartInterval,
Retries: int(hc.Retries),
}
}
}
// TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB. // TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB.
var err error var err error
@@ -610,19 +627,23 @@ 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{
NanoCPUs: spec.Container.Resources.CPU, NanoCPUs: spec.Container.Resources.CPU,
Memory: spec.Container.Resources.Memory, Memory: spec.Container.Resources.Memory,
MemoryReservation: spec.Container.Resources.MemoryReservation, MemoryReservation: spec.Container.Resources.MemoryReservation,
Devices: toDockerDevices(spec.Container.Resources.Devices),
DeviceRequests: spec.Container.Resources.DeviceReservations, DeviceRequests: spec.Container.Resources.DeviceReservations,
Ulimits: toDockerUlimits(spec.Container.Resources.Ulimits),
}, },
// Restart service containers if they exit or a machine restarts unless they are explicitly stopped. // Restart service containers if they exit or a machine restarts unless they are explicitly stopped.
// For one-off containers and batch jobs we plan to use a different service type/mode. // For one-off containers and batch jobs we plan to use a different service type/mode.
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,
} }
@@ -877,6 +898,40 @@ func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
return dockerOpts return dockerOpts
} }
func toDockerUlimits(ulimits map[string]api.Ulimit) []*units.Ulimit {
if len(ulimits) == 0 {
return nil
}
dockerUlimits := make([]*units.Ulimit, 0, len(ulimits))
for name, u := range ulimits {
dockerUlimits = append(dockerUlimits, &units.Ulimit{
Name: name,
Soft: u.Soft,
Hard: u.Hard,
})
}
return dockerUlimits
}
func toDockerDevices(devices []api.DeviceMapping) []container.DeviceMapping {
if len(devices) == 0 {
return nil
}
dockerDevices := make([]container.DeviceMapping, 0, len(devices))
for _, d := range devices {
dockerDevices = append(dockerDevices, container.DeviceMapping{
PathOnHost: d.HostPath,
PathInContainer: d.ContainerPath,
CgroupPermissions: d.CgroupPermissions,
})
}
return dockerDevices
}
// verifyDockerVolumesExist checks if the Docker named volumes referenced in the mounts exist on the machine. // verifyDockerVolumesExist checks if the Docker named volumes referenced in the mounts exist on the machine.
func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mount) error { func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mount) error {
for _, m := range mounts { for _, m := range mounts {
+1 -1
View File
@@ -103,7 +103,7 @@ func createIptablesChains() error {
} }
firstRejectRuleNum := 0 firstRejectRuleNum := 0
for _, line := range strings.Split(string(out), "\n") { for line := range strings.SplitSeq(string(out), "\n") {
fields := strings.Fields(line) fields := strings.Fields(line)
if len(fields) < 2 { if len(fields) < 2 {
continue continue
+22 -13
View File
@@ -701,20 +701,29 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
return nil, status.Errorf(codes.Internal, "generate machine name: %v", err) return nil, status.Errorf(codes.Internal, "generate machine name: %v", err)
} }
} }
// Use the public and all routable IPs as endpoints.
ips, err := network.ListRoutableIPs() // Use explicitly provided WireGuard endpoints, or use the routable IPs on the machine and its public IP
if err != nil { // if not provided. The IPs are auto-detected.
return nil, status.Errorf(codes.Internal, "list routable IPs: %v", err) var endpoints []*pb.IPPort
}
publicIP, pubIPErr := network.GetPublicIP() publicIP, pubIPErr := network.GetPublicIP()
// Ignore the error if failed to get the public IP using API services.
if pubIPErr == nil && !slices.Contains(ips, publicIP) { if len(req.WireguardEndpoints) > 0 {
ips = append(ips, publicIP) endpoints = req.WireguardEndpoints
} } else {
endpoints := make([]*pb.IPPort, len(ips)) ips, err := network.ListRoutableIPs()
for i, addr := range ips { if err != nil {
addrPort := netip.AddrPortFrom(addr, network.WireGuardPort) return nil, status.Errorf(codes.Internal, "list routable IPs: %v", err)
endpoints[i] = pb.NewIPPort(addrPort) }
// Ignore the error if failed to get the public IP using API services.
if pubIPErr == nil && !slices.Contains(ips, publicIP) {
ips = append(ips, publicIP)
}
endpoints = make([]*pb.IPPort, len(ips))
for i, addr := range ips {
addrPort := netip.AddrPortFrom(addr, network.WireGuardPort)
endpoints[i] = pb.NewIPPort(addrPort)
}
} }
// Register the new machine in the cluster to populate the state and get its ID and subnet. // Register the new machine in the cluster to populate the state and get its ID and subnet.
+6
View File
@@ -31,6 +31,12 @@ func ListRoutableIPs() ([]netip.Addr, error) {
// Skip Docker bridge interfaces. // Skip Docker bridge interfaces.
continue continue
} }
if iface.Name == "tailscale0" {
// Skip Tailscale interface as we don't want double-tunneling. Also, MTU=1280 on tailscale0 is too small
// for tunneling IPv6 used for uncloud management traffic.
// See Discord thread: https://discord.com/channels/1371726032104587335/1477823130670993408
continue
}
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagRunning == 0 || iface.Flags&net.FlagLoopback != 0 { if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagRunning == 0 || iface.Flags&net.FlagLoopback != 0 {
// Skip interfaces: // Skip interfaces:
+30
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"context" "context"
_ "embed" _ "embed"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -75,6 +76,35 @@ func (s *Store) DBVersion(ctx context.Context) (int64, error) {
return version, nil return version, nil
} }
type MissingChange struct {
ActorID string
StartVersion int64
EndVersion int64
}
// KnownMissingChanges returns a list of currently known missing changes in the Corrosion database.
func (s *Store) KnownMissingChanges(ctx context.Context) ([]MissingChange, error) {
rows, err := s.corro.QueryContext(ctx, "SELECT actor_id, start, end FROM __corro_bookkeeping_gaps")
if err != nil {
return nil, fmt.Errorf("query missing changes: %w", err)
}
defer rows.Close()
var changes []MissingChange
for rows.Next() {
var c MissingChange
var actorBytes []byte
if err = rows.Scan(&actorBytes, &c.StartVersion, &c.EndVersion); err != nil {
return nil, fmt.Errorf("scan missing change: %w", err)
}
c.ActorID = hex.EncodeToString(actorBytes)
changes = append(changes, c)
}
return changes, nil
}
func (s *Store) CreateMachine(ctx context.Context, m *pb.MachineInfo) error { func (s *Store) CreateMachine(ctx context.Context, m *pb.MachineInfo) error {
mJSON, err := protojson.Marshal(m) mJSON, err := protojson.Marshal(m)
if err != nil { if err != nil {
+1 -1
View File
@@ -116,7 +116,7 @@ func (p *Proxy) handleConnection(ctx context.Context, localConn net.Conn) {
}() }()
// Wait for both copies to complete or context cancel. // Wait for both copies to complete or context cancel.
for i := 0; i < 2; i++ { for range 2 {
select { select {
case <-ctx.Done(): case <-ctx.Done():
// Close connections to abort ongoing copies. // Close connections to abort ongoing copies.
+11
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
osuser "os/user"
"strconv" "strconv"
"time" "time"
@@ -13,7 +14,17 @@ import (
) )
func Connect(user, host string, port int, sshKeyPath string) (*ssh.Client, error) { func Connect(user, host string, port int, sshKeyPath string) (*ssh.Client, error) {
// Use the current OS user if no user is specified to be make it consistent with ssh CLI behavior.
if user == "" {
if u, err := osuser.Current(); err == nil {
user = u.Username
}
}
if port == 0 {
port = 22
}
addr := net.JoinHostPort(host, strconv.Itoa(port)) addr := net.JoinHostPort(host, strconv.Itoa(port))
// Try to connect using SSH agent only. // Try to connect using SSH agent only.
agentAuth, agentClose, agentErr := sshAgentAuth() agentAuth, agentClose, agentErr := sshAgentAuth()
if agentErr == nil { if agentErr == nil {
+39 -25
View File
@@ -1,12 +1,15 @@
package sshexec package sshexec
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type SSHCLIRemote struct { type SSHCLIRemote struct {
@@ -25,48 +28,59 @@ 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",
if r.port != 0 && r.port != 22 { // Disable pseudo-terminal allocation to prevent SSH from executing as a login shell.
args = append(args, "-p", strconv.Itoa(r.port)) "-T",
} }
if r.port != 0 {
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)
} }
args = append(args, r.user+"@"+r.host) dst := r.host
return args if r.user != "" {
dst = fmt.Sprintf("%s@%s", r.user, dst)
}
args = append(args, dst, cmd)
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
} }
+73 -33
View File
@@ -1,12 +1,13 @@
package sshexec package sshexec
import ( import (
"context"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestSSHCLIRemote_buildSSHArgs(t *testing.T) { func TestSSHCLIRemote_newSSHCommand(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
@@ -15,47 +16,85 @@ 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: "default port, no key", name: "host only",
user: "root", host: "example.com",
host: "example.com", cmd: "whoami",
port: 22, expected: []string{
keyPath: "", "ssh", "-o", "ConnectTimeout=5", "-T", "example.com", "whoami",
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"}, },
}, },
{ {
name: "custom port", name: "with user",
user: "ubuntu", user: "root",
host: "192.168.1.10", host: "example.com",
port: 2222, cmd: "whoami",
keyPath: "", expected: []string{
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "ubuntu@192.168.1.10"}, "ssh", "-o", "ConnectTimeout=5", "-T", "root@example.com", "whoami",
},
}, },
{ {
name: "with key path", name: "with port",
user: "root", host: "example.com",
host: "example.com", port: 2222,
port: 22, cmd: "whoami",
keyPath: "/path/to/key", expected: []string{
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com"}, "ssh", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "example.com", "whoami",
},
}, },
{ {
name: "all options", name: "with key",
user: "admin", host: "example.com",
host: "server.local", keyPath: "/path/to/key",
port: 2222, cmd: "whoami",
keyPath: "~/.ssh/id_rsa", expected: []string{
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "~/.ssh/id_rsa", "admin@server.local"}, "ssh", "-o", "ConnectTimeout=5", "-T", "-i", "/path/to/key", "example.com", "whoami",
},
}, },
{ {
name: "port zero (default)", name: "user and port",
user: "root", user: "ubuntu",
host: "example.com", host: "192.168.1.10",
port: 0, port: 2222,
keyPath: "", cmd: "ls -la",
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"}, expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "ubuntu@192.168.1.10", "ls -la",
},
},
{
name: "user and key",
user: "root",
host: "example.com",
keyPath: "/path/to/key",
cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-i", "/path/to/key", "root@example.com", "whoami",
},
},
{
name: "port and key",
host: "example.com",
port: 22,
keyPath: "~/.ssh/id_rsa",
cmd: "whoami",
expected: []string{
"ssh", "-o", "ConnectTimeout=5", "-T", "-p", "22", "-i", "~/.ssh/id_rsa", "example.com", "whoami",
},
},
{
name: "all options",
user: "admin",
host: "server.local",
port: 2222,
keyPath: "~/.ssh/id_rsa",
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'",
},
}, },
} }
@@ -69,8 +108,9 @@ func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
port: tt.port, port: tt.port,
keyPath: tt.keyPath, keyPath: tt.keyPath,
} }
result := remote.buildSSHArgs() execCmd := remote.newSSHCommand(context.Background(), tt.cmd)
assert.Equal(t, tt.expected, result) assert.Equal(t, tt.expected, execCmd.Args)
assert.NotNil(t, execCmd.Cancel, "Cancel should be set for graceful shutdown")
}) })
} }
} }
+67
View File
@@ -0,0 +1,67 @@
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
[[tools."aqua:vektra/mockery"]]
version = "3.5.3"
backend = "aqua:vektra/mockery"
[tools."aqua:vektra/mockery"."platforms.linux-x64"]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
[tools."aqua:vektra/mockery"."platforms.macos-arm64"]
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz"
[[tools.go]]
version = "1.26.1"
backend = "core:go"
[tools.go."platforms.linux-x64"]
checksum = "sha256:031f088e5d955bab8657ede27ad4e3bc5b7c1ba281f05f245bcc304f327c987a"
url = "https://dl.google.com/go/go1.26.1.linux-amd64.tar.gz"
[tools.go."platforms.macos-arm64"]
checksum = "sha256:353df43a7811ce284c8938b5f3c7df40b7bfb6f56cb165b150bc40b5e2dd541f"
url = "https://dl.google.com/go/go1.26.1.darwin-arm64.tar.gz"
[[tools.golangci-lint]]
version = "2.11.2"
backend = "aqua:golangci/golangci-lint"
[tools.golangci-lint."platforms.linux-x64"]
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
[tools.golangci-lint."platforms.macos-arm64"]
checksum = "sha256:11e9549b955bae4bfb71aa3b5c2762e446e2d3f975fe963d6d767b8f745d9344"
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-arm64.tar.gz"
[[tools.node]]
version = "22.20.0"
backend = "core:node"
[tools.node."platforms.linux-x64"]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
[tools.node."platforms.macos-arm64"]
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-darwin-arm64.tar.gz"
[[tools.protoc]]
version = "27.3"
backend = "aqua:protocolbuffers/protobuf/protoc"
[[tools.protoc-gen-go]]
version = "1.34.2"
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
[tools.protoc-gen-go."platforms.linux-x64"]
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
[tools.protoc-gen-go."platforms.macos-arm64"]
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
[[tools.protoc-gen-go-grpc]]
version = "1.5.1"
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
+67
View File
@@ -0,0 +1,67 @@
[settings]
experimental = true
[tools]
"aqua:vektra/mockery" = "3.5.3"
go = "1.26.1"
golangci-lint = "2.11.2"
node = "22.20.0"
protoc = "27.3"
protoc-gen-go = "1.34.2"
protoc-gen-go-grpc = "1.5.1"
[vars]
ucind_image = "ghcr.io/psviderski/ucind:latest"
[env]
_.file = ".env"
[tasks."build:uncloudd-amd64"]
description = "Build uncloudd for linux/amd64"
run = """
VERSION=$(git describe --tags --always --dirty) \
GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/psviderski/uncloud/internal/version.version=$VERSION" \
-o uncloudd-linux-amd64 ./cmd/uncloudd
"""
outputs = ["uncloudd-linux-amd64"]
[tasks."build:uncloudd-arm64"]
description = "Build uncloudd for linux/arm64"
run = """
VERSION=$(git describe --tags --always --dirty) \
GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/psviderski/uncloud/internal/version.version=$VERSION" \
-o uncloudd-linux-arm64 ./cmd/uncloudd
"""
outputs = ["uncloudd-linux-arm64"]
[tasks."mise:lock"]
description = "Update mise.lock with hashes for all platforms"
run = "mise lock --platform linux-x64,macos-arm64"
[tasks.proto]
description = "Regenerate gRPC API code from .proto files"
run = """
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
--proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto
"""
[tasks.uc]
description = "Run the Uncloud CLI"
run = "go run ./cmd/uncloud"
[tasks.ucind]
description = "Run ucind CLI for managing local Docker-based clusters"
run = "go run ./cmd/ucind"
[tasks."ucind:image"]
description = "Build the ucind Docker image"
run = "docker build -t {{vars.ucind_image}} --target ucind ."
[tasks."ucind:cleanup"]
description = "Remove ucind managed containers and networks"
run = """
echo "==> Removing ucind managed containers..."
docker ps --filter "label=ucind.managed" -q | xargs docker rm -f
echo "==> Removing ucind managed networks..."
docker network ls --filter "label=ucind.managed" -q | xargs docker network rm -f
"""
+12 -2
View File
@@ -21,11 +21,14 @@ type ContainerClient interface {
CreateContainer( CreateContainer(
ctx context.Context, serviceID string, spec ServiceSpec, machineID string, ctx context.Context, serviceID string, spec ServiceSpec, machineID string,
) (container.CreateResponse, error) ) (container.CreateResponse, error)
ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error)
InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error) InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error)
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error) RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
WaitContainerHealthy(
ctx context.Context, serviceNameOrID, containerNameOrID string, opts WaitContainerHealthyOptions,
) error
} }
type DNSClient interface { type DNSClient interface {
@@ -57,3 +60,10 @@ type VolumeClient interface {
ListVolumes(ctx context.Context, filter *VolumeFilter) ([]MachineVolume, error) ListVolumes(ctx context.Context, filter *VolumeFilter) ([]MachineVolume, error)
RemoveVolume(ctx context.Context, machineNameOrID, volumeName string, force bool) error RemoveVolume(ctx context.Context, machineNameOrID, volumeName string, force bool) error
} }
// AsPtr returns a pointer to the given value. Useful for optional fields in API structs.
//
//go:fix inline
func AsPtr[T any](v T) *T {
return new(v)
}
+4 -9
View File
@@ -8,11 +8,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// uint64Ptr is a convenience function to create a pointer to a uint64 value
func uint64Ptr(v uint64) *uint64 {
return &v
}
func TestConfigMount_GetNumericUid(t *testing.T) { func TestConfigMount_GetNumericUid(t *testing.T) {
t.Parallel() t.Parallel()
@@ -30,12 +25,12 @@ func TestConfigMount_GetNumericUid(t *testing.T) {
{ {
name: "valid numeric uid", name: "valid numeric uid",
uid: "1000", uid: "1000",
expected: uint64Ptr(1000), expected: new(uint64(1000)),
}, },
{ {
name: "zero uid", name: "zero uid",
uid: "0", uid: "0",
expected: uint64Ptr(0), expected: new(uint64(0)),
}, },
{ {
name: "invalid non-numeric uid", name: "invalid non-numeric uid",
@@ -96,12 +91,12 @@ func TestConfigMount_GetNumericGid(t *testing.T) {
{ {
name: "valid numeric gid", name: "valid numeric gid",
gid: "1000", gid: "1000",
expected: uint64Ptr(1000), expected: new(uint64(1000)),
}, },
{ {
name: "zero gid", name: "zero gid",
gid: "0", gid: "0",
expected: uint64Ptr(0), expected: new(uint64(0)),
}, },
{ {
name: "invalid non-numeric gid", name: "invalid non-numeric gid",
+73
View File
@@ -3,7 +3,9 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/netip" "net/netip"
"os"
"strings" "strings"
"time" "time"
@@ -41,6 +43,19 @@ func (c *Container) CreatedTime() time.Time {
return c.created return c.created
} }
// HasHealthcheck returns true if the container has a health check configured.
func (c *Container) HasHealthcheck() bool {
hc := c.Config.Healthcheck
if hc == nil {
return false
}
if len(hc.Test) > 0 && hc.Test[0] == "NONE" {
return false
}
return true
}
// Healthy determines if the container is running and healthy. // Healthy determines if the container is running and healthy.
// A running container with no health check configured is considered healthy. // A running container with no health check configured is considered healthy.
func (c *Container) Healthy() bool { func (c *Container) Healthy() bool {
@@ -244,3 +259,61 @@ func (c *ServiceContainer) UnmarshalJSON(data []byte) error {
return nil return nil
} }
// DefaultHealthMonitorPeriod is the default duration (5 seconds) to wait before checking that the container is still
// running and not restarting. Can be overridden with the UNCLOUD_HEALTH_MONITOR_PERIOD environment variable
// (e.g. "10s" or "0").
var DefaultHealthMonitorPeriod = defaultHealthMonitorPeriod()
func defaultHealthMonitorPeriod() time.Duration {
if v, ok := os.LookupEnv("UNCLOUD_HEALTH_MONITOR_PERIOD"); ok {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return 5 * time.Second
}
// WaitContainerHealthyOptions configures the behaviour of WaitContainerHealthy.
type WaitContainerHealthyOptions struct {
// MonitorPeriod is how long to wait before checking that the container is still running and not restarting.
// Containers with a health check that become healthy before the period ends succeed early.
// nil means use the default DefaultHealthMonitorPeriod.
// Zero skips the monitoring and checks the container's health immediately after starting.
MonitorPeriod *time.Duration
}
// ExecOptions contains configuration for executing a command in a container.
type ExecOptions struct {
// Command is the command to run in the container.
Command []string
// AttachStdin attaches the stdin stream to the exec session.
AttachStdin bool
// AttachStdout attaches the stdout stream to the exec session.
AttachStdout bool
// AttachStderr attaches the stderr stream to the exec session.
AttachStderr bool
// Tty allocates a pseudo-TTY for the exec session.
Tty bool
// Detach runs the command in the background without attaching to streams.
Detach bool
//// Not yet implemented fields
// User specifies the user to run the command as.
User string
// Privileged runs the command in privileged mode.
Privileged bool
// WorkingDir sets the working directory for the command.
WorkingDir string
// Env sets environment variables for the command.
Env []string
// Client-side only fields (not serialized, not sent to server)
// Stdin is the input stream. Defaults to os.Stdin if nil.
Stdin io.Reader `json:"-"`
// Stdout is the output stream. Defaults to os.Stdout if nil.
Stdout io.Writer `json:"-"`
// Stderr is the error stream. Defaults to os.Stderr if nil.
Stderr io.Writer `json:"-"`
}
-37
View File
@@ -1,37 +0,0 @@
package api
import "io"
// ExecOptions contains configuration for executing a command in a container.
type ExecOptions struct {
// Command is the command to run in the container.
Command []string
// AttachStdin attaches the stdin stream to the exec session.
AttachStdin bool
// AttachStdout attaches the stdout stream to the exec session.
AttachStdout bool
// AttachStderr attaches the stderr stream to the exec session.
AttachStderr bool
// Tty allocates a pseudo-TTY for the exec session.
Tty bool
// Detach runs the command in the background without attaching to streams.
Detach bool
//// Not yet implemented fields
// User specifies the user to run the command as.
User string
// Privileged runs the command in privileged mode.
Privileged bool
// WorkingDir sets the working directory for the command.
WorkingDir string
// Env sets environment variables for the command.
Env []string
// Client-side only fields (not serialized, not sent to server)
// Stdin is the input stream. Defaults to os.Stdin if nil.
Stdin io.Reader `json:"-"`
// Stdout is the output stream. Defaults to os.Stdout if nil.
Stdout io.Writer `json:"-"`
// Stderr is the error stream. Defaults to os.Stderr if nil.
Stderr io.Writer `json:"-"`
}
+22 -1
View File
@@ -17,6 +17,27 @@ type ContainerResources struct {
// MemoryReservation is the minimum amount of memory (in bytes) the container needs to run efficiently. // MemoryReservation is the minimum amount of memory (in bytes) the container needs to run efficiently.
// TODO: implement a placement constraint that checks available memory on machines. // TODO: implement a placement constraint that checks available memory on machines.
MemoryReservation int64 MemoryReservation int64
// Device reservations/requests for access to things like GPUs // Devices provides direct access to host devices.
Devices []DeviceMapping
// 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 map[string]Ulimit
}
// DeviceMapping represents a device mapping between host and container.
type DeviceMapping struct {
// HostPath is the path to the device on the host.
HostPath string
// ContainerPath is the path to the device in the container.
ContainerPath string
// CgroupPermissions is the cgroup permissions for the device (e.g., "rwm").
CgroupPermissions string
}
type Ulimit struct {
Soft int64
Hard int64
} }
+76 -14
View File
@@ -7,6 +7,7 @@ import (
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"time"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/distribution/reference" "github.com/distribution/reference"
@@ -19,6 +20,13 @@ const (
ServiceModeReplicated = "replicated" ServiceModeReplicated = "replicated"
ServiceModeGlobal = "global" ServiceModeGlobal = "global"
// UpdateOrderStartFirst starts the new container before stopping the old one.
// This minimizes downtime but briefly runs both containers.
UpdateOrderStartFirst = "start-first"
// UpdateOrderStopFirst stops the old container before starting the new one.
// This prevents data corruption for stateful services but causes brief downtime.
UpdateOrderStopFirst = "stop-first"
// PullPolicyAlways means the image is always pulled from the registry. // PullPolicyAlways means the image is always pulled from the registry.
PullPolicyAlways = "always" PullPolicyAlways = "always"
// PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where // PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where
@@ -47,6 +55,8 @@ type ServiceSpec struct {
// Caddy is the optional Caddy reverse proxy configuration for the service. // Caddy is the optional Caddy reverse proxy configuration for the service.
// Caddy and Ports cannot be specified simultaneously. // Caddy and Ports cannot be specified simultaneously.
Caddy *CaddySpec `json:",omitempty"` Caddy *CaddySpec `json:",omitempty"`
// Configs is list of configuration objects that can be mounted into the container.
Configs []ConfigSpec
// Container defines the desired state of each container in the service. // Container defines the desired state of each container in the service.
Container ContainerSpec Container ContainerSpec
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty. // Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
@@ -59,10 +69,10 @@ 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"`
// UpdateConfig configures how the service is updated during a deployment.
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
// Configs is list of configuration objects that can be mounted into the container.
Configs []ConfigSpec
} }
// CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined. // CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined.
@@ -234,12 +244,17 @@ type ContainerSpec struct {
// Entrypoint overrides the default ENTRYPOINT of the image. // Entrypoint overrides the default ENTRYPOINT of the image.
Entrypoint []string Entrypoint []string
// Env defines the environment variables to set inside the container. // Env defines the environment variables to set inside the container.
Env EnvVars Env EnvVars
Image string // Healthcheck defines the health check configuration for the container or overrides the health check options
// defined in the image. If nil, the image's default health check is used.
Healthcheck *HealthcheckSpec `json:",omitempty"`
Image string
// Run a custom init inside the container. If nil, use the daemon's configured settings. // Run a custom init inside the container. If nil, use the daemon's configured settings.
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.
@@ -247,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
@@ -330,6 +348,15 @@ func (s *ContainerSpec) Clone() ContainerSpec {
spec.Entrypoint = make([]string, len(s.Entrypoint)) spec.Entrypoint = make([]string, len(s.Entrypoint))
copy(spec.Entrypoint, s.Entrypoint) copy(spec.Entrypoint, s.Entrypoint)
} }
if s.Env != nil {
spec.Env = make(EnvVars, len(s.Env))
maps.Copy(spec.Env, s.Env)
}
if s.Healthcheck != nil {
hc := *s.Healthcheck
hc.Test = slices.Clone(s.Healthcheck.Test)
spec.Healthcheck = &hc
}
if s.LogDriver != nil { if s.LogDriver != nil {
logDriver := *s.LogDriver logDriver := *s.LogDriver
if s.LogDriver.Options != nil { if s.LogDriver.Options != nil {
@@ -337,12 +364,6 @@ func (s *ContainerSpec) Clone() ContainerSpec {
} }
spec.LogDriver = &logDriver spec.LogDriver = &logDriver
} }
if s.Env != nil {
spec.Env = make(EnvVars, len(s.Env))
for k, v := range s.Env {
spec.Env[k] = v
}
}
if s.Volumes != nil { if s.Volumes != nil {
spec.Volumes = make([]string, len(s.Volumes)) spec.Volumes = make([]string, len(s.Volumes))
copy(spec.Volumes, s.Volumes) copy(spec.Volumes, s.Volumes)
@@ -359,10 +380,18 @@ 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 {
spec.Resources.Ulimits = maps.Clone(s.Resources.Ulimits)
}
if s.Resources.Devices != nil {
spec.Resources.Devices = slices.Clone(s.Resources.Devices)
}
if s.Resources.DeviceReservations != nil {
spec.Resources.DeviceReservations = slices.Clone(s.Resources.DeviceReservations)
}
return spec return spec
} }
@@ -380,6 +409,26 @@ func (e EnvVars) ToSlice() []string {
return env return env
} }
// HealthcheckSpec defines the health check configuration for a container.
type HealthcheckSpec struct {
// Test is the command used to check health.
// Formats: ["CMD", args...], ["CMD-SHELL", "command"], or ["NONE"] to disable.
Test []string `json:",omitempty"`
// Interval is the time between health checks.
// Zero means to inherit the value from the image or use the Docker default (30s) if not defined in the image.
Interval time.Duration `json:",omitempty"`
// Timeout is how long to wait before considering the checck to have hung.
Timeout time.Duration `json:",omitempty"`
// StartPeriod is the initialisation time for a container before the retries start to count down.
StartPeriod time.Duration `json:",omitempty"`
// StartInterval is the time between health checks during the start period.
StartInterval time.Duration `json:",omitempty"`
// Retries is the number of consecutive failures needed to consider a container unhealthy.
Retries uint `json:",omitempty"`
// Disable disables the health check defined in the image. true is equivalent to setting Test to ["NONE"].
Disable bool `json:",omitempty"`
}
type LogDriver struct { type LogDriver struct {
// Name of the logging driver to use. // Name of the logging driver to use.
Name string Name string
@@ -387,6 +436,19 @@ type LogDriver struct {
Options map[string]string Options map[string]string
} }
// UpdateConfig configures how a service is updated during a deployment.
type UpdateConfig struct {
// Order specifies the order of operations during an update.
// Valid values are "start-first" (default for stateless services) and "stop-first" (default for services with
// volumes). Empty value means the strategy will determine the order based on service characteristics.
Order string `json:",omitempty"`
// MonitorPeriod is how long to wait after starting a container before checking that it's still running
// and not restarting. Containers with a health check that become healthy before the period ends succeed early.
// nil means use the default api.DefaultHealthMonitorPeriod.
// Zero skips the monitoring and checks the container's health immediately after starting.
MonitorPeriod *time.Duration `json:",omitempty"`
}
type RunServiceResponse struct { type RunServiceResponse struct {
ID string ID string
Name string Name string
+15 -7
View File
@@ -4,16 +4,11 @@ import (
"os" "os"
"testing" "testing"
"github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"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
@@ -211,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{
@@ -218,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{
@@ -231,6 +227,12 @@ func TestContainerSpec_Clone(t *testing.T) {
CPU: 1234, CPU: 1234,
Memory: 2345, Memory: 2345,
MemoryReservation: 3456, MemoryReservation: 3456,
Devices: []DeviceMapping{
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
},
DeviceReservations: []container.DeviceRequest{
{Count: 1, Capabilities: [][]string{{"gpu"}}, Driver: "nvidia"},
},
}, },
Sysctls: map[string]string{ Sysctls: map[string]string{
"net.ipv4.ip_forward": "1", "net.ipv4.ip_forward": "1",
@@ -263,6 +265,9 @@ func TestContainerSpec_Clone(t *testing.T) {
original.ConfigMounts[0].ContainerPath = stringModified original.ConfigMounts[0].ContainerPath = stringModified
*original.ConfigMounts[0].Mode = 0o755 // Modify the Mode pointer value *original.ConfigMounts[0].Mode = 0o755 // Modify the Mode pointer value
original.Sysctls["net.ipv4.ip_forward"] = stringModified original.Sysctls["net.ipv4.ip_forward"] = stringModified
original.Resources.Devices[0].HostPath = stringModified
original.Resources.DeviceReservations[0].Count = 2
original.Resources.DeviceReservations[0].Driver = stringModified
assert.False(t, original.Equals(cloned)) assert.False(t, original.Equals(cloned))
// Assert cloned values are unchanged // Assert cloned values are unchanged
@@ -283,6 +288,9 @@ func TestContainerSpec_Clone(t *testing.T) {
assert.Equal(t, int64(1234), cloned.Resources.CPU) assert.Equal(t, int64(1234), cloned.Resources.CPU)
assert.Equal(t, int64(2345), cloned.Resources.Memory) assert.Equal(t, int64(2345), cloned.Resources.Memory)
assert.Equal(t, int64(3456), cloned.Resources.MemoryReservation) assert.Equal(t, int64(3456), cloned.Resources.MemoryReservation)
assert.Equal(t, "/dev/sda", cloned.Resources.Devices[0].HostPath)
assert.Equal(t, 1, cloned.Resources.DeviceReservations[0].Count)
assert.Equal(t, "nvidia", cloned.Resources.DeviceReservations[0].Driver)
assert.Equal(t, "1000:1000", cloned.User) assert.Equal(t, "1000:1000", cloned.User)
assert.Equal(t, "/data", cloned.Volumes[0]) assert.Equal(t, "/data", cloned.Volumes[0])
assert.Equal(t, "/data", cloned.VolumeMounts[0].ContainerPath) assert.Equal(t, "/data", cloned.VolumeMounts[0].ContainerPath)
+3 -6
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"fmt" "fmt"
"maps"
"reflect" "reflect"
"slices" "slices"
"sort" "sort"
@@ -165,18 +166,14 @@ func (v *VolumeSpec) Clone() VolumeSpec {
driver := *v.VolumeOptions.Driver driver := *v.VolumeOptions.Driver
if driver.Options != nil { if driver.Options != nil {
driver.Options = make(map[string]string, len(v.VolumeOptions.Driver.Options)) driver.Options = make(map[string]string, len(v.VolumeOptions.Driver.Options))
for k, val := range v.VolumeOptions.Driver.Options { maps.Copy(driver.Options, v.VolumeOptions.Driver.Options)
driver.Options[k] = val
}
} }
opts.Driver = &driver opts.Driver = &driver
} }
if opts.Labels != nil { if opts.Labels != nil {
opts.Labels = make(map[string]string, len(v.VolumeOptions.Labels)) opts.Labels = make(map[string]string, len(v.VolumeOptions.Labels))
for k, val := range v.VolumeOptions.Labels { maps.Copy(opts.Labels, v.VolumeOptions.Labels)
opts.Labels[k] = val
}
} }
spec.VolumeOptions = &opts spec.VolumeOptions = &opts
+22
View File
@@ -0,0 +1,22 @@
package compose
import (
"github.com/compose-spec/compose-go/v2/types"
)
// ContextExtensionKey is the top-level Compose extension key for specifying the cluster context.
const ContextExtensionKey = "x-context"
// ClusterContext extracts the x-context value from the project's top-level extensions.
// Returns an empty string if x-context is not set.
func ClusterContext(project *types.Project) string {
v, ok := project.Extensions[ContextExtensionKey]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
+45
View File
@@ -0,0 +1,45 @@
package compose
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClusterContext(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "no x-context",
content: `
services:
web:
image: nginx
`,
want: "",
},
{
name: "x-context set",
content: `
x-context: prod
services:
web:
image: nginx
`,
want: "prod",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.content)
require.NoError(t, err)
assert.Equal(t, tt.want, ClusterContext(project))
})
}
}
+9 -10
View File
@@ -13,6 +13,7 @@ import (
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy" "github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
) )
@@ -27,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 *deploy.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) {
@@ -58,11 +59,11 @@ func NewDeploymentWithStrategy(ctx context.Context, cli Client, project *types.P
}, nil }, nil
} }
func (d *Deployment) Plan(ctx context.Context) (deploy.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 := deploy.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
@@ -89,9 +90,7 @@ func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error)
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.
@@ -104,7 +103,7 @@ func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error)
// 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)
} }
} }
@@ -123,7 +122,7 @@ func (d *Deployment) ServiceSpec(name string) (api.ServiceSpec, error) {
} }
// PlanVolumes checks if the external volumes exist and plans the creation of missing volumes. // PlanVolumes checks if the external volumes exist and plans the creation of missing volumes.
func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.CreateVolumeOperation, error) { func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*operation.CreateVolumeOperation, error) {
if len(d.Project.Volumes) == 0 { if len(d.Project.Volumes) == 0 {
// No volumes to check or create. // No volumes to check or create.
return nil, nil return nil, nil
@@ -145,7 +144,7 @@ func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.Crea
} }
// Generate operations to create scheduled missing volumes. // Generate operations to create scheduled missing volumes.
var ops []*deploy.CreateVolumeOperation var ops []*operation.CreateVolumeOperation
for machineID, volumes := range scheduledVolumes { for machineID, volumes := range scheduledVolumes {
for _, v := range volumes { for _, v := range volumes {
machineName := machineID machineName := machineID
@@ -153,7 +152,7 @@ func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.Crea
machineName = m.Info.Name machineName = m.Info.Name
} }
ops = append(ops, &deploy.CreateVolumeOperation{ ops = append(ops, &operation.CreateVolumeOperation{
MachineID: machineID, MachineID: machineID,
MachineName: machineName, MachineName: machineName,
VolumeSpec: v, VolumeSpec: v,
+2 -2
View File
@@ -11,7 +11,7 @@ const MachinesExtensionKey = "x-machines"
type MachinesSource []string type MachinesSource []string
// DecodeMapstructure implements custom decoding for multiple input types // DecodeMapstructure implements custom decoding for multiple input types
func (m *MachinesSource) DecodeMapstructure(value interface{}) error { func (m *MachinesSource) DecodeMapstructure(value any) error {
switch v := value.(type) { switch v := value.(type) {
case *MachinesSource: case *MachinesSource:
// Handle case where compose-go passes a pointer to an already created instance // Handle case where compose-go passes a pointer to an already created instance
@@ -38,7 +38,7 @@ func (m *MachinesSource) DecodeMapstructure(value interface{}) error {
} }
*m = MachinesSource(machines) *m = MachinesSource(machines)
return nil return nil
case []interface{}: case []any:
// Support interface array that may come from YAML parsing // Support interface array that may come from YAML parsing
machineNames := make([]string, 0, len(v)) machineNames := make([]string, 0, len(v))
for i, machine := range v { for i, machine := range v {
+130
View File
@@ -0,0 +1,130 @@
package compose
import (
"context"
"fmt"
"strconv"
"strings"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
)
// Plan holds the compose-level deployment plan with volume and service operations.
type Plan struct {
Volumes []*operation.CreateVolumeOperation
Services []*deploy.ServicePlan
}
// IsEmpty returns true if the plan has no volume or service operations.
func (p *Plan) IsEmpty() bool {
return len(p.Volumes) == 0 && len(p.Services) == 0
}
// Format renders the entire deployment plan as a styled tree with a summary footer.
func (p *Plan) Format() string {
var out strings.Builder
// Format volume operations.
for _, op := range p.Volumes {
out.WriteString(op.Format())
out.WriteString("\n")
}
if len(p.Volumes) > 0 {
out.WriteString("\n")
}
// Format service plans.
for _, svcPlan := range p.Services {
out.WriteString(svcPlan.Format())
out.WriteString("\n")
}
// Format summary footer.
summary := p.formatSummary()
out.WriteString(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
out.WriteString("\n")
out.WriteString(summary)
out.WriteString("\n")
return out.String()
}
// formatSummary counts all operations across the plan and renders the summary footer.
func (p *Plan) formatSummary() string {
var createCount, startFirstCount, stopFirstCount, removeCount int
machines := make(map[string]struct{})
for _, op := range p.Volumes {
machines[op.MachineID] = struct{}{}
createCount++
}
for _, svcPlan := range p.Services {
for _, op := range svcPlan.Operations {
switch o := op.(type) {
case *operation.RunContainerOperation:
machines[o.MachineID] = struct{}{}
createCount++
case *operation.ReplaceContainerOperation:
machines[o.MachineID] = struct{}{}
if o.Order == api.UpdateOrderStopFirst {
stopFirstCount++
} else {
startFirstCount++
}
case *operation.RemoveContainerOperation:
machines[o.MachineID] = struct{}{}
removeCount++
case *operation.StopContainerOperation:
machines[o.MachineID] = struct{}{}
removeCount++
}
}
}
var parts []string
if createCount > 0 {
parts = append(parts,
tui.BoldGreen.Render(strconv.Itoa(createCount))+" "+tui.Green.Render("create"))
}
if startFirstCount > 0 {
parts = append(parts,
tui.BoldGreen.Render(strconv.Itoa(startFirstCount))+" "+tui.Green.Render("replace (start-first)"))
}
if stopFirstCount > 0 {
parts = append(parts,
tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)"))
}
if removeCount > 0 {
parts = append(parts,
tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove"))
}
machinesWord := "machines"
if len(machines) == 1 {
machinesWord = "machine"
}
parts = append(parts, fmt.Sprintf("across %s %s", tui.Bold.Render(strconv.Itoa(len(machines))), machinesWord))
sep := " " + tui.Faint.Render("·") + " "
return strings.Join(parts, sep)
}
// Execute runs all volume operations followed by all service operations.
func (p *Plan) Execute(ctx context.Context, cli operation.Client) error {
for _, op := range p.Volumes {
if err := op.Execute(ctx, cli); err != nil {
return err
}
}
for _, sp := range p.Services {
if err := sp.Execute(ctx, cli); err != nil {
return err
}
}
return nil
}
+21
View File
@@ -6,13 +6,23 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
composecli "github.com/compose-spec/compose-go/v2/cli" composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/transform"
"github.com/compose-spec/compose-go/v2/tree"
"github.com/compose-spec/compose-go/v2/types" "github.com/compose-spec/compose-go/v2/types"
"github.com/psviderski/uncloud/pkg/api"
) )
var registerComposeOverrides sync.Once
// LoadProject loads a Compose project from the default locations or the given paths. // LoadProject loads a Compose project from the default locations or the given paths.
func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) { func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) {
registerComposeOverrides.Do(func() {
transform.RegisterDefaultValue("services.*.deploy.update_config", setUpdateConfigDefaults)
})
defaultOpts := []composecli.ProjectOptionsFn{ defaultOpts := []composecli.ProjectOptionsFn{
// First apply os.Environment, always wins. // First apply os.Environment, always wins.
composecli.WithOsEnv, composecli.WithOsEnv,
@@ -92,3 +102,14 @@ func removeProjectPrefixFromNames(project *types.Project) {
project.Volumes[name] = vol project.Volumes[name] = vol
} }
} }
// setUpdateConfigDefaults sets default values for deploy.update_config attributes when not specified in the compose file.
func setUpdateConfigDefaults(data any, _ tree.Path, _ bool) (any, error) {
switch v := data.(type) {
case map[string]any:
if _, ok := v["monitor"]; !ok {
v["monitor"] = api.DefaultHealthMonitorPeriod.String()
}
}
return data, nil
}
+116 -15
View File
@@ -5,12 +5,14 @@ import (
"maps" "maps"
"os" "os"
"slices" "slices"
"time"
"github.com/compose-spec/compose-go/v2/types" "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/opencontainers/go-digest" "github.com/opencontainers/go-digest"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
cdi "tags.cncf.io/container-device-interface/pkg/parser"
) )
func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.ServiceSpec, error) { func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.ServiceSpec, error) {
@@ -43,18 +45,20 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
spec := api.ServiceSpec{ spec := api.ServiceSpec{
Container: api.ContainerSpec{ Container: api.ContainerSpec{
CapAdd: service.CapAdd, CapAdd: service.CapAdd,
CapDrop: service.CapDrop, CapDrop: service.CapDrop,
Command: service.Command, Command: service.Command,
Entrypoint: service.Entrypoint, Entrypoint: service.Entrypoint,
Env: env, Env: env,
Image: service.Image, Healthcheck: healthcheckFromCompose(service.HealthCheck),
Init: service.Init, Image: service.Image,
Privileged: service.Privileged, Init: service.Init,
PullPolicy: pullPolicy, PidMode: service.Pid,
Resources: resourcesFromCompose(service), Privileged: service.Privileged,
Sysctls: service.Sysctls, PullPolicy: pullPolicy,
User: service.User, Resources: resourcesFromCompose(service),
Sysctls: service.Sysctls,
User: service.User,
}, },
Name: serviceName, Name: serviceName,
Mode: api.ServiceModeReplicated, Mode: api.ServiceModeReplicated,
@@ -82,6 +86,11 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
} }
} }
if service.StopGracePeriod != nil {
d := time.Duration(*service.StopGracePeriod)
spec.Container.StopGracePeriod = &d
}
if service.Scale != nil { if service.Scale != nil {
spec.Replicas = uint(*service.Scale) spec.Replicas = uint(*service.Scale)
} }
@@ -97,6 +106,22 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
default: default:
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode) return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
} }
if cfg := service.Deploy.UpdateConfig; cfg != nil {
switch cfg.Order {
case "":
// No order specified, use default behavior.
case "start-first":
spec.UpdateConfig.Order = api.UpdateOrderStartFirst
case "stop-first":
spec.UpdateConfig.Order = api.UpdateOrderStopFirst
default:
return spec, fmt.Errorf("unsupported deploy.update_config.order: '%s'", cfg.Order)
}
d := time.Duration(cfg.Monitor)
spec.UpdateConfig.MonitorPeriod = &d
}
} }
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well? // TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
@@ -120,11 +145,62 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
return spec, nil return spec, nil
} }
func healthcheckFromCompose(hc *types.HealthCheckConfig) *api.HealthcheckSpec {
if hc == nil {
return nil
}
if hc.Disable {
return &api.HealthcheckSpec{Disable: true}
}
spec := &api.HealthcheckSpec{Test: hc.Test}
if hc.Interval != nil {
spec.Interval = time.Duration(*hc.Interval)
}
if hc.Timeout != nil {
spec.Timeout = time.Duration(*hc.Timeout)
}
if hc.StartPeriod != nil {
spec.StartPeriod = time.Duration(*hc.StartPeriod)
}
if hc.StartInterval != nil {
spec.StartInterval = time.Duration(*hc.StartInterval)
}
if hc.Retries != nil {
spec.Retries = uint(*hc.Retries)
}
return spec
}
func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources { func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
resources := api.ContainerResources{ resources := 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),
}
// Convert device mappings, separating CDI devices from regular device mappings.
// CDI devices are identified when Source == Target and the source is a qualified CDI name.
var cdiDeviceNames []string
for _, dev := range service.Devices {
if dev.Source == dev.Target && cdi.IsQualifiedName(dev.Source) {
cdiDeviceNames = append(cdiDeviceNames, dev.Source)
continue
}
resources.Devices = append(resources.Devices, api.DeviceMapping{
HostPath: dev.Source,
ContainerPath: dev.Target,
CgroupPermissions: dev.Permissions,
})
}
if len(cdiDeviceNames) > 0 {
resources.DeviceReservations = append(resources.DeviceReservations, container.DeviceRequest{
Driver: "cdi",
DeviceIDs: cdiDeviceNames,
})
} }
// Convert GPU device requests from compose format, appending "gpu" capability. // Convert GPU device requests from compose format, appending "gpu" capability.
@@ -271,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
} }
@@ -293,6 +367,33 @@ func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.Vol
return spec return spec
} }
func ulimitsFromCompose(ulimits map[string]*types.UlimitsConfig) map[string]api.Ulimit {
if len(ulimits) == 0 {
return nil
}
res := make(map[string]api.Ulimit, len(ulimits))
for name, u := range ulimits {
soft := u.Soft
hard := u.Hard
if u.Single != 0 {
if soft == 0 {
soft = u.Single
}
if hard == 0 {
hard = u.Single
}
}
res[name] = api.Ulimit{
Soft: int64(soft),
Hard: int64(hard),
}
}
return res
}
// validateServicesExtensions validates extension combinations across all services in the project. // validateServicesExtensions validates extension combinations across all services in the project.
func validateServicesExtensions(project *types.Project) error { func validateServicesExtensions(project *types.Project) error {
for _, service := range project.Services { for _, service := range project.Services {
+429 -1
View File
@@ -8,6 +8,7 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
composecli "github.com/compose-spec/compose-go/v2/cli" composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -93,7 +94,7 @@ func TestServiceSpecFromCompose(t *testing.T) {
}, },
}, },
{ {
name: "full-spec", name: "full spec",
filename: "compose-full-spec.yaml", filename: "compose-full-spec.yaml",
want: map[string]api.ServiceSpec{ want: map[string]api.ServiceSpec{
"test": { "test": {
@@ -109,6 +110,14 @@ func TestServiceSpecFromCompose(t *testing.T) {
"EMPTY": "", "EMPTY": "",
"VAR": "value", "VAR": "value",
}, },
Healthcheck: &api.HealthcheckSpec{
Test: []string{"CMD", "curl", "-f", "http://localhost"},
Interval: 1*time.Minute + 30*time.Second,
Timeout: 10 * time.Second,
Retries: 5,
StartPeriod: 15 * time.Second,
StartInterval: 2 * time.Second,
},
Image: "nginx:latest", Image: "nginx:latest",
Init: &initTrue, Init: &initTrue,
LogDriver: &api.LogDriver{ LogDriver: &api.LogDriver{
@@ -118,13 +127,27 @@ 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{
"nofile": {Soft: 20000, Hard: 40000},
"nproc": {Soft: 65535, Hard: 65535},
},
Devices: []api.DeviceMapping{
{HostPath: "/dev/ttyUSB0", ContainerPath: "/dev/ttyUSB0", CgroupPermissions: "rw"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
},
DeviceReservations: []container.DeviceRequest{
{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",
}, },
@@ -139,6 +162,15 @@ func TestServiceSpecFromCompose(t *testing.T) {
VolumeName: "data1", VolumeName: "data1",
ContainerPath: "/data1", ContainerPath: "/data1",
}, },
{
VolumeName: "bind-53f1acbf1de61e9e608c93effca23791674e463d02bb7aaca7c625804aef1926",
ContainerPath: "/path/in/container",
ReadOnly: true,
},
{
VolumeName: "data3-labeled",
ContainerPath: "/data3",
},
{ {
VolumeName: "data2-alias", VolumeName: "data2-alias",
ContainerPath: "/data2/long/syntax", ContainerPath: "/data2/long/syntax",
@@ -173,8 +205,24 @@ func TestServiceSpecFromCompose(t *testing.T) {
Mode: api.PortModeHost, Mode: api.PortModeHost,
}, },
}, },
Placement: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
Replicas: 3, Replicas: 3,
UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
Volumes: []api.VolumeSpec{ Volumes: []api.VolumeSpec{
{
Name: "bind-53f1acbf1de61e9e608c93effca23791674e463d02bb7aaca7c625804aef1926",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/path/on/host",
CreateHostPath: true,
Propagation: mount.Propagation("rprivate"),
},
},
{ {
Name: "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90", Name: "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90",
Type: api.VolumeTypeBind, Type: api.VolumeTypeBind,
@@ -207,11 +255,22 @@ func TestServiceSpecFromCompose(t *testing.T) {
}, },
}, },
}, },
{
Name: "data3-labeled",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "data3-labeled",
NoCopy: true,
SubPath: "app/data",
Labels: map[string]string{"env": "test"},
},
},
{ {
Name: "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542", Name: "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542",
Type: api.VolumeTypeTmpfs, Type: api.VolumeTypeTmpfs,
TmpfsOptions: &mount.TmpfsOptions{ TmpfsOptions: &mount.TmpfsOptions{
SizeBytes: 10 * units.MiB, SizeBytes: 10 * units.MiB,
Mode: os.FileMode(1770),
}, },
}, },
}, },
@@ -257,6 +316,8 @@ func TestServiceSpecFromCompose(t *testing.T) {
} }
func TestServiceSpecFromCompose_Caddy(t *testing.T) { func TestServiceSpecFromCompose_Caddy(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
composeYAML string composeYAML string
@@ -405,6 +466,8 @@ services:
} }
func TestServiceSpecFromCompose_GPUs(t *testing.T) { func TestServiceSpecFromCompose_GPUs(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
composeYAML string composeYAML string
@@ -592,6 +655,8 @@ services:
} }
func TestServiceSpecFromCompose_VolumeDriverOpts(t *testing.T) { func TestServiceSpecFromCompose_VolumeDriverOpts(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
composeYAML string composeYAML string
@@ -699,7 +764,269 @@ volumes:
} }
} }
func TestServiceSpecFromCompose_Ulimits(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expected map[string]api.Ulimit
}{
{
name: "single ulimit with soft and hard limits",
composeYAML: `
services:
db:
image: postgres
ulimits:
nofile:
soft: 20000
hard: 40000
`,
expected: map[string]api.Ulimit{
"nofile": {
Soft: 20000,
Hard: 40000,
},
},
},
{
name: "single ulimit with single value (soft=hard)",
composeYAML: `
services:
db:
image: postgres
ulimits:
nproc: 65535
`,
expected: map[string]api.Ulimit{
"nproc": {
Soft: 65535,
Hard: 65535,
},
},
},
{
name: "multiple ulimits",
composeYAML: `
services:
db:
image: postgres
ulimits:
nofile:
soft: 20000
hard: 40000
nproc: 65535
`,
expected: map[string]api.Ulimit{
"nofile": {
Soft: 20000,
Hard: 40000,
},
"nproc": {
Soft: 65535,
Hard: 65535,
},
},
},
{
name: "empty ulimits",
composeYAML: `
services:
db:
image: postgres
ulimits: {}
`,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "db")
require.NoError(t, err)
assert.Equal(t, tt.expected, spec.Container.Resources.Ulimits)
})
}
}
func TestServiceSpecFromCompose_UpdateConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expected api.UpdateConfig
expectError bool
}{
{
name: "no update_config",
composeYAML: `
services:
test:
image: nginx
`,
expected: api.UpdateConfig{},
},
{
name: "empty update_config",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config: {}
`,
expected: api.UpdateConfig{
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with unsupported attributes ignored",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
parallelism: 1
`,
expected: api.UpdateConfig{
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with stop-first order",
composeYAML: `
services:
test:
image: postgres
deploy:
update_config:
order: stop-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with start-first order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: start-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStartFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with invalid order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: invalid-order
`,
expectError: true,
},
{
name: "update_config with replicas and order",
composeYAML: `
services:
test:
image: nginx
deploy:
replicas: 3
update_config:
order: stop-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with custom monitor",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
monitor: 10s
`,
expected: api.UpdateConfig{
MonitorPeriod: api.AsPtr(10 * time.Second),
},
},
{
name: "update_config with monitor and order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: start-first
monitor: 30s
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStartFirst,
MonitorPeriod: api.AsPtr(30 * time.Second),
},
},
{
name: "update_config with zero monitor skips monitoring",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
monitor: 0s
`,
expected: api.UpdateConfig{
MonitorPeriod: api.AsPtr(time.Duration(0)),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "test")
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, spec.UpdateConfig)
})
}
}
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) { func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
composeYAML string composeYAML string
@@ -856,3 +1183,104 @@ services:
}) })
} }
} }
func TestServiceSpecFromCompose_Devices(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expectedDevices []api.DeviceMapping
expectedReservations []container.DeviceRequest
}{
{
name: "simple device",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/dri
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
},
},
{
name: "device with target and permissions",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/sda:/dev/xvda:r
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "r"},
},
},
{
name: "multiple devices",
composeYAML: `
services:
test:
image: nginx
devices:
- "/dev/ttyUSB0:/dev/ttyUSB0:rw"
- /dev/sda:/dev/xvda
- "/dev/dri"
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/ttyUSB0", ContainerPath: "/dev/ttyUSB0", CgroupPermissions: "rw"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
},
},
{
name: "CDI device",
composeYAML: `
services:
test:
image: nginx
devices:
- vendor.com/class=device1
`,
expectedReservations: []container.DeviceRequest{
{Driver: "cdi", DeviceIDs: []string{"vendor.com/class=device1"}},
},
},
{
name: "mixed CDI and regular devices",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/dri
- vendor.com/class=device1
- nvidia.com/gpu=0
- /dev/sda:/dev/xvda:r
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "r"},
},
expectedReservations: []container.DeviceRequest{
{Driver: "cdi", DeviceIDs: []string{"vendor.com/class=device1", "nvidia.com/gpu=0"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "test")
require.NoError(t, err)
assert.Equal(t, tt.expectedDevices, spec.Container.Resources.Devices)
assert.Equal(t, tt.expectedReservations, spec.Container.Resources.DeviceReservations)
})
}
}
+40
View File
@@ -6,11 +6,26 @@ services:
- ALL - ALL
command: ["nginx", "updated", "command"] command: ["nginx", "updated", "command"]
cpus: 0.5 cpus: 0.5
pid: host
deploy:
update_config:
order: stop-first
devices:
- /dev/ttyUSB0:/dev/ttyUSB0:rw
- /dev/sda:/dev/xvda
entrypoint: ["/updated-docker-entrypoint.sh"] entrypoint: ["/updated-docker-entrypoint.sh"]
environment: environment:
BOOL: "true" BOOL: "true"
EMPTY: "" EMPTY: ""
VAR: value VAR: value
gpus: all
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 1m30s
timeout: 10s
retries: 5
start_period: 15s
start_interval: 2s
image: nginx:latest image: nginx:latest
init: true init: true
logging: logging:
@@ -20,15 +35,35 @@ 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
stop_grace_period: 30s
sysctls: sysctls:
- net.ipv4.ip_forward=1 - net.ipv4.ip_forward=1
ulimits:
nofile:
soft: 20000
hard: 40000
nproc: 65535
user: nginx:nginx user: nginx:nginx
volumes: volumes:
- /etc/passwd:/host/etc/passwd:ro - /etc/passwd:/host/etc/passwd:ro
- data1:/data1 - data1:/data1
- type: bind
source: /path/on/host
target: /path/in/container
read_only: true
bind:
create_host_path: true
propagation: rprivate
- type: volume
source: data3-labeled
target: /data3
volume:
nocopy: true
subpath: app/data
- type: volume - type: volume
source: data2-alias source: data2-alias
target: /data2/long/syntax target: /data2/long/syntax
@@ -37,6 +72,8 @@ services:
target: /tmpfs target: /tmpfs
tmpfs: tmpfs:
size: 10485760 size: 10485760
mode: 1770
x-machines: ["machine-1", "machine-2"]
x-ports: x-ports:
- test.example.com:80/https - test.example.com:80/https
- 8000/http - 8000/http
@@ -55,5 +92,8 @@ volumes:
data2-alias: data2-alias:
name: data2 name: data2
driver: local driver: local
data3-labeled:
labels:
env: test
data-external: data-external:
external: true external: true
+15 -1
View File
@@ -24,6 +24,16 @@ type SSHConnectorConfig struct {
SockPath string SockPath string
} }
// Destination returns the destination string that can be passed to ssh CLI in the format [user@]host.
func (cfg *SSHConnectorConfig) Destination() string {
dst := cfg.Host
if cfg.User != "" {
dst = fmt.Sprintf("%s@%s", cfg.User, dst)
}
return dst
}
// SSHConnector establishes a connection to the machine API through an SSH tunnel to the machine. // SSHConnector establishes a connection to the machine API through an SSH tunnel to the machine.
type SSHConnector struct { type SSHConnector struct {
config SSHConnectorConfig config SSHConnectorConfig
@@ -48,7 +58,11 @@ func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
var err error var err error
c.client, err = sshexec.Connect(c.config.User, c.config.Host, c.config.Port, c.config.KeyPath) c.client, err = sshexec.Connect(c.config.User, c.config.Host, c.config.Port, c.config.KeyPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("SSH login to %s@%s:%d: %w", c.config.User, c.config.Host, c.config.Port, err) dst := c.config.Destination()
if c.config.Port != 0 {
dst = fmt.Sprintf("%s:%d", dst, c.config.Port)
}
return nil, fmt.Errorf("SSH login to %s: %w", dst, err)
} }
} }
+127 -71
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"os"
"path/filepath"
"strconv" "strconv"
"github.com/docker/cli/cli/connhelper/commandconn" "github.com/docker/cli/cli/connhelper/commandconn"
@@ -17,101 +19,96 @@ import (
// and running `uncloudd dial-stdio` on the remote machine. // and running `uncloudd dial-stdio` on the remote machine.
type SSHCLIConnector struct { type SSHCLIConnector struct {
config SSHConnectorConfig config SSHConnectorConfig
conn net.Conn // Path to SSH control socket for connection reuse.
controlSockPath string
} }
func NewSSHCLIConnector(cfg *SSHConnectorConfig) *SSHCLIConnector { func NewSSHCLIConnector(cfg *SSHConnectorConfig) *SSHCLIConnector {
return &SSHCLIConnector{config: *cfg} return &SSHCLIConnector{
config: *cfg,
controlSockPath: controlSocketPath(),
}
} }
// sshCLIDialer implements proxy.ContextDialer by spawning SSH processes with -W flag. // controlSocketPath returns a unique control socket path for the SSH connection.
type sshCLIDialer struct { // Returns an empty string if unable to find or create a suitable path.
config SSHConnectorConfig func controlSocketPath() string {
} // %C is expanded by `ssh` to a hash of user, local and remote hostnames, port, and the contents
// of the ProxyJump option. This ensures that shared connections are uniquely identified.
sockName := fmt.Sprintf("uc_control_%%C.sock")
// buildDialArgs constructs SSH command arguments for -W flag dialing. // Prefer XDG_RUNTIME_DIR if set, fall back to ~/.ssh if it exists.
func (d *sshCLIDialer) buildDialArgs(address string) []string { if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
args := []string{} return filepath.Join(dir, sockName)
}
// Add connection timeout to fail fast when node is down. if home, err := os.UserHomeDir(); err == nil {
args = append(args, "-o", "ConnectTimeout=5") sshDir := filepath.Join(home, ".ssh")
if fi, sErr := os.Stat(sshDir); sErr == nil && fi.IsDir() {
// Add port if non-standard. return filepath.Join(sshDir, sockName)
if d.config.Port != 0 && d.config.Port != 22 { }
args = append(args, "-p", strconv.Itoa(d.config.Port))
} }
// Add identity file if specified. // Last resort: create a subdirectory in temp with restricted permissions.
if d.config.KeyPath != "" { tmpDir := filepath.Join(os.TempDir(), fmt.Sprintf("uncloud-%d", os.Getuid()))
args = append(args, "-i", d.config.KeyPath) path := filepath.Join(tmpDir, sockName)
if len(path)-2+40 < 104 { // 40 chars for %C hash, 104 is typical UNIX socket path limit
if err := os.MkdirAll(tmpDir, 0o700); err == nil {
return path
}
} }
// Add -W flag for stdin/stdout forwarding to target address. return ""
args = append(args, "-W", address)
// Add user@host.
args = append(args, d.config.User+"@"+d.config.Host)
return args
}
// DialContext establishes a connection to the target address through an SSH tunnel using -W flag.
func (d *sshCLIDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
// Only support TCP connections.
if network != "tcp" {
return nil, fmt.Errorf("unsupported network type: %s", network)
}
// Build SSH command arguments.
args := d.buildDialArgs(address)
// Create connection using commandconn.
conn, err := commandconn.New(ctx, "ssh", args...)
if err != nil {
return nil, fmt.Errorf("SSH connection to %s@%s for dialing %s: %w", d.config.User, d.config.Host, address, err)
}
return conn, nil
} }
func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) { func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
// Build SSH command arguments. // Create gRPC client with a dialer that spawns a new SSH connection on demand.
args := c.buildSSHArgs() // Each dial attempt runs `ssh ... uncloudd dial-stdio`, reusing the control socket if available.
// Create connection using commandconn.
conn, err := commandconn.New(ctx, "ssh", args...)
if err != nil {
return nil, fmt.Errorf("SSH connection to %s@%s: %w", c.config.User, c.config.Host, err)
}
c.conn = conn
// Create gRPC client over the connection.
// Use a custom dialer that returns our existing connection.
grpcConn, err := grpc.NewClient( grpcConn, err := grpc.NewClient(
"passthrough:///", // Dummy target since we're using a custom dialer. "passthrough:///", // Dummy target since we're using a custom dialer.
grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(defaultServiceConfig), grpc.WithDefaultServiceConfig(defaultServiceConfig),
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return c.conn, nil args := c.buildSSHArgs()
conn, err := commandconn.New(ctx, "ssh", args...)
if err != nil {
return nil, fmt.Errorf("SSH connection to %s: %w", c.config.Destination(), err)
}
return conn, nil
}), }),
) )
if err != nil { if err != nil {
c.conn.Close()
return nil, fmt.Errorf("create machine API client: %w", err) return nil, fmt.Errorf("create machine API client: %w", err)
} }
return grpcConn, nil return grpcConn, nil
} }
// buildSSHArgs constructs the SSH command arguments. // buildSSHArgs constructs the SSH command arguments to run `uncloudd dial-stdio` on the remote machine reusing
// the established connection via control socket.
func (c *SSHCLIConnector) buildSSHArgs() []string { func (c *SSHCLIConnector) buildSSHArgs() []string {
args := []string{} var args []string
// Add control socket options for connection reuse if available.
if c.controlSockPath != "" {
args = append(args, "-o", "ControlMaster=auto")
args = append(args, "-o", "ControlPath="+c.controlSockPath)
// Keep the established connection alive for a short duration after the last session closes to allow reuse.
controlPersist := "10m"
// Override the default duration with the UNCLOUD_SSH_CONTROL_PERSIST env variable.
if v := os.Getenv("UNCLOUD_SSH_CONTROL_PERSIST"); v != "" {
controlPersist = v
}
args = append(args, "-o", "ControlPersist="+controlPersist)
}
// Add connection timeout to fail fast when node is down. // Add connection timeout to fail fast when node is down.
args = append(args, "-o", "ConnectTimeout=5") args = append(args, "-o", "ConnectTimeout=5")
// Disable pseudo-terminal allocation to prevent SSH from executing as a login shell.
args = append(args, "-T")
// Add port if non-standard. // Add port if specified.
if c.config.Port != 0 && c.config.Port != 22 { if c.config.Port != 0 {
args = append(args, "-p", strconv.Itoa(c.config.Port)) args = append(args, "-p", strconv.Itoa(c.config.Port))
} }
@@ -120,8 +117,8 @@ func (c *SSHCLIConnector) buildSSHArgs() []string {
args = append(args, "-i", c.config.KeyPath) args = append(args, "-i", c.config.KeyPath)
} }
// Add user@host. // Add [user@]host destination.
args = append(args, c.config.User+"@"+c.config.Host) args = append(args, c.config.Destination())
// Add remote command: uncloudd dial-stdio // Add remote command: uncloudd dial-stdio
args = append(args, "uncloudd", "dial-stdio") args = append(args, "uncloudd", "dial-stdio")
@@ -141,15 +138,74 @@ func (c *SSHCLIConnector) Dialer() (proxy.ContextDialer, error) {
} }
return &sshCLIDialer{ return &sshCLIDialer{
config: c.config, config: c.config,
controlSockPath: c.controlSockPath,
}, nil }, nil
} }
func (c *SSHCLIConnector) Close() error { func (c *SSHCLIConnector) Close() error {
if c.conn != nil { // Individual connections are managed by gRPC and closed when the gRPC connection closes.
err := c.conn.Close() // The SSH control socket may persist for connection reuse across CLI invocations.
c.conn = nil
return err
}
return nil return nil
} }
// sshCLIDialer implements proxy.ContextDialer by spawning SSH processes with -W flag.
type sshCLIDialer struct {
config SSHConnectorConfig
// Shared control socket path from SSHCLIConnector for connection reuse.
controlSockPath string
}
// buildDialArgs constructs SSH command arguments for -W flag dialing.
func (d *sshCLIDialer) buildDialArgs(address string) []string {
var args []string
if d.controlSockPath != "" {
// Try to reuse the existing control connection without initiating a new one.
// Falls back to direct connection if the control socket is not available.
args = append(args, "-o", "ControlMaster=no")
args = append(args, "-o", "ControlPath="+d.controlSockPath)
}
// Add connection timeout to fail fast when node is down.
args = append(args, "-o", "ConnectTimeout=5")
// Disable pseudo-terminal allocation to prevent SSH from executing as a login shell.
args = append(args, "-T")
// Add port if specified.
if d.config.Port != 0 {
args = append(args, "-p", strconv.Itoa(d.config.Port))
}
// Add identity file if specified.
if d.config.KeyPath != "" {
args = append(args, "-i", d.config.KeyPath)
}
// Add -W flag for stdin/stdout forwarding to target address.
args = append(args, "-W", address)
// Add [user@]host destination.
args = append(args, d.config.Destination())
return args
}
// DialContext establishes a connection to the target address through an SSH tunnel using -W flag.
func (d *sshCLIDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
// Only support TCP connections.
if network != "tcp" {
return nil, fmt.Errorf("unsupported network type: %s", network)
}
// Build SSH command arguments.
args := d.buildDialArgs(address)
// Create connection using docker's commandconn.
conn, err := commandconn.New(ctx, "ssh", args...)
if err != nil {
return nil, fmt.Errorf("SSH connection to %s for dialing %s: %w", d.config.Destination(), address, err)
}
return conn, nil
}
+96 -33
View File
@@ -1,6 +1,7 @@
package connector package connector
import ( import (
"strings"
"testing" "testing"
"github.com/psviderski/uncloud/internal/machine" "github.com/psviderski/uncloud/internal/machine"
@@ -11,17 +12,28 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
config SSHConnectorConfig config SSHConnectorConfig
expected []string controlSockPath string
expected []string
}{ }{
{ {
name: "basic connection", name: "basic connection with control socket",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
}, },
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio"},
},
{
name: "basic connection without control socket",
config: SSHConnectorConfig{
User: "root",
Host: "example.com",
},
controlSockPath: "",
expected: []string{"-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio"},
}, },
{ {
name: "with custom port", name: "with custom port",
@@ -30,7 +42,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
Host: "example.com", Host: "example.com",
Port: 2222, Port: 2222,
}, },
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "root@example.com", "uncloudd", "dial-stdio"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "root@example.com", "uncloudd", "dial-stdio"},
}, },
{ {
name: "with identity file", name: "with identity file",
@@ -39,7 +52,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
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", "uncloudd", "dial-stdio"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio"},
}, },
{ {
name: "with custom socket path", name: "with custom socket path",
@@ -48,7 +62,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
Host: "example.com", Host: "example.com",
SockPath: "/custom/path/uncloud.sock", SockPath: "/custom/path/uncloud.sock",
}, },
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"},
}, },
{ {
name: "with default socket path (not included)", name: "with default socket path (not included)",
@@ -57,7 +72,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
Host: "example.com", Host: "example.com",
SockPath: machine.DefaultUncloudSockPath, SockPath: machine.DefaultUncloudSockPath,
}, },
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio"},
}, },
{ {
name: "all options combined", name: "all options combined",
@@ -68,16 +84,28 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
KeyPath: "/path/to/key", KeyPath: "/path/to/key",
SockPath: "/custom/path/uncloud.sock", SockPath: "/custom/path/uncloud.sock",
}, },
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"},
}, },
{ {
name: "port 22 not included (default)", name: "port 0 not included",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
Port: 0, Port: 0,
}, },
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio"},
},
{
name: "port 22 included when explicit",
config: SSHConnectorConfig{
User: "root",
Host: "example.com",
Port: 22,
},
controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "-p", "22", "root@example.com", "uncloudd", "dial-stdio"},
}, },
} }
@@ -85,7 +113,7 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
c := &SSHCLIConnector{config: tt.config} c := &SSHCLIConnector{config: tt.config, controlSockPath: tt.controlSockPath}
got := c.buildSSHArgs() got := c.buildSSHArgs()
assert.Equal(t, tt.expected, got) assert.Equal(t, tt.expected, got)
}) })
@@ -96,61 +124,77 @@ func TestSSHCLIDialer_buildDialArgs(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
config SSHConnectorConfig config SSHConnectorConfig
address string controlSockPath string
expected []string address string
expected []string
}{ }{
{ {
name: "basic connection", name: "basic connection without control socket",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
}, },
address: "10.210.1.1:5000", controlSockPath: "",
expected: []string{"-o", "ConnectTimeout=5", "-W", "10.210.1.1:5000", "root@example.com"}, address: "10.210.1.1:5000",
expected: []string{"-o", "ConnectTimeout=5", "-T", "-W", "10.210.1.1:5000", "root@example.com"},
}, },
{ {
name: "custom port", name: "basic connection with control socket",
config: SSHConnectorConfig{
User: "root",
Host: "example.com",
},
controlSockPath: "/tmp/test.sock",
address: "10.210.1.1:5000",
expected: []string{"-o", "ControlMaster=no", "-o", "ControlPath=/tmp/test.sock", "-o", "ConnectTimeout=5", "-T", "-W", "10.210.1.1:5000", "root@example.com"},
},
{
name: "custom port with control socket",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
Port: 2222, Port: 2222,
}, },
address: "10.210.1.1:5000", controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-W", "10.210.1.1:5000", "root@example.com"}, address: "10.210.1.1:5000",
expected: []string{"-o", "ControlMaster=no", "-o", "ControlPath=/tmp/test.sock", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "-W", "10.210.1.1:5000", "root@example.com"},
}, },
{ {
name: "with identity file", name: "with identity file and control socket",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
Port: 22, Port: 22,
KeyPath: "/home/user/.ssh/id_rsa", KeyPath: "/home/user/.ssh/id_rsa",
}, },
address: "10.210.1.1:5000", controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"}, address: "10.210.1.1:5000",
expected: []string{"-o", "ControlMaster=no", "-o", "ControlPath=/tmp/test.sock", "-o", "ConnectTimeout=5", "-T", "-p", "22", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"},
}, },
{ {
name: "custom port with identity file", name: "custom port with identity file and control socket",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
Port: 2222, Port: 2222,
KeyPath: "/home/user/.ssh/id_rsa", KeyPath: "/home/user/.ssh/id_rsa",
}, },
address: "10.210.1.1:5000", controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"}, address: "10.210.1.1:5000",
expected: []string{"-o", "ControlMaster=no", "-o", "ControlPath=/tmp/test.sock", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"},
}, },
{ {
name: "zero port defaults to 22", name: "port 0 not included",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
Port: 0, Port: 0,
}, },
address: "10.210.1.1:5000", controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ConnectTimeout=5", "-W", "10.210.1.1:5000", "root@example.com"}, address: "10.210.1.1:5000",
expected: []string{"-o", "ControlMaster=no", "-o", "ControlPath=/tmp/test.sock", "-o", "ConnectTimeout=5", "-T", "-W", "10.210.1.1:5000", "root@example.com"},
}, },
} }
@@ -158,9 +202,28 @@ func TestSSHCLIDialer_buildDialArgs(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
d := &sshCLIDialer{config: tt.config} d := &sshCLIDialer{config: tt.config, controlSockPath: tt.controlSockPath}
got := d.buildDialArgs(tt.address) got := d.buildDialArgs(tt.address)
assert.Equal(t, tt.expected, got) assert.Equal(t, tt.expected, got)
}) })
} }
} }
func TestControlSocketPath(t *testing.T) {
// Note: Cannot use t.Parallel() because a subtest uses t.Setenv().
path1 := controlSocketPath()
path2 := controlSocketPath()
assert.Equal(t, path1, path2)
assert.True(t, strings.HasSuffix(path1, ".sock"))
assert.Contains(t, path1, "%C")
t.Run("uses XDG_RUNTIME_DIR when set", func(t *testing.T) {
runDir := "/user/runtime/dir"
t.Setenv("XDG_RUNTIME_DIR", runDir)
path := controlSocketPath()
assert.True(t, strings.HasPrefix(path, runDir))
})
}
+151
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/containerd/errdefs" "github.com/containerd/errdefs"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
@@ -17,6 +18,9 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
// TODO: format container and machine IDs in 'Container %s on %s' events as bold.
// Consider formatting containers as <service_name>/<short-container-id>.
// CreateContainer creates a new container for the given service on the specified machine. // CreateContainer creates a new container for the given service on the specified machine.
func (cli *Client) CreateContainer( func (cli *Client) CreateContainer(
ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string, ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string,
@@ -354,3 +358,150 @@ func (cli *Client) ExecContainer(
return exitCode, nil return exitCode, nil
} }
// WaitContainerHealthy polls the container until it is considered running and healthy.
//
// For containers without a health check, it waits for the monitor period and then verifies the container
// is still running and not restarting.
//
// For containers with a health check, it waits until Docker reports healthy or unhealthy. During the monitor period,
// unhealthy status is treated as retryable (the container may be recovering from a transient crash).
// After the monitor period, unhealthy becomes a permanent failure.
func (cli *Client) WaitContainerHealthy(
ctx context.Context, serviceNameOrID, containerNameOrID string, opts api.WaitContainerHealthyOptions,
) error {
// First inspect to get container info, machine name, and health check config.
mc, err := cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
if err != nil {
return fmt.Errorf("inspect container: %w", err)
}
machine, err := cli.InspectMachine(ctx, mc.MachineID)
if err != nil {
return fmt.Errorf("inspect machine '%s': %w", mc.MachineID, err)
}
pw := progress.ContextWriter(ctx)
eventID := fmt.Sprintf("Container %s on %s", mc.Container.Name, machine.Machine.Name)
var monitor time.Duration
if opts.MonitorPeriod == nil {
monitor = api.DefaultHealthMonitorPeriod
} else {
monitor = *opts.MonitorPeriod
}
pw.Event(progress.NewEvent(eventID, progress.Working, fmt.Sprintf("Monitoring (%s)", monitor)))
// For containers without a health check, just wait for the monitor period and then check the container
// is still running and not restarting.
if !mc.Container.HasHealthcheck() {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(monitor):
}
mc, err := cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
if err != nil {
return fmt.Errorf("inspect container: %w", err)
}
if mc.Container.Healthy() {
pw.Event(progress.RunningEvent(eventID))
return nil
}
humanState, _ := mc.Container.HumanState()
pw.Event(progress.ErrorMessageEvent(eventID, fmt.Sprintf("Unhealthy (%s)", humanState)))
if mc.Container.State.Restarting {
return fmt.Errorf("container is restarting after monitor period (%s): exit_code=%d",
monitor, mc.Container.State.ExitCode)
}
return fmt.Errorf("container is unhealthy after monitor period (%s): %s", monitor, humanState)
}
// For containers with a health check, wait until Docker reports healthy or unhealthy.
mctx := proxyToMachine(ctx, machine.Machine)
mctx, cancel := context.WithTimeout(mctx, healthcheckTimeout(mc.Container.Config.Healthcheck))
defer cancel()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
monitorDeadline := time.Now().Add(monitor)
for {
select {
case <-mctx.Done():
return mctx.Err()
case <-ticker.C:
ctr, err := cli.Docker.InspectServiceContainer(mctx, mc.Container.ID)
if err != nil {
pw.Event(progress.NewEvent(eventID, progress.Working,
fmt.Sprintf("Health checking (failed to inspect container: %v)", err)))
continue
}
// Reset the event status if previous inspect failed.
eventStatus := fmt.Sprintf("Monitoring (%s)", monitor)
if time.Now().After(monitorDeadline) {
// TODO: provide more details about running checks or waiting so the user can see what's going on.
eventStatus = "Health checking"
}
pw.Event(progress.NewEvent(eventID, progress.Working, eventStatus))
if ctr.Healthy() {
pw.Event(progress.Healthy(eventID))
return nil
}
if time.Now().Before(monitorDeadline) {
continue
}
if ctr.State.Health.Status == container.Unhealthy {
humanState, _ := ctr.HumanState()
pw.Event(progress.ErrorMessageEvent(eventID, fmt.Sprintf("Unhealthy (%s)", humanState)))
if ctr.State.Restarting {
return fmt.Errorf("container is restarting after monitor period (%s): exit_code=%d",
monitor, ctr.State.ExitCode)
}
return fmt.Errorf("container is unhealthy after monitor period (%s): %s", monitor, humanState)
}
}
}
}
const (
// defaultDockerHealthcheckInterval is the default Docker interval between health check runs.
defaultDockerHealthcheckInterval = 30 * time.Second
// defaultDockerHealthcheckTimeout is the default Docker timeout for each health check run.
defaultDockerHealthcheckTimeout = 30 * time.Second
// defaultDockerHealthcheckRetries is the default Docker number of consecutive failures needed
// to consider the container unhealthy.
defaultDockerHealthcheckRetries = 3
)
// healthcheckTimeout computes the maximum time to wait for a container to become healthy based on
// its health check config. This is the worst case timeout to stop polling in case something goes wrong and Docker
// doesn't report the container as unhealthy after it should.
func healthcheckTimeout(hc *container.HealthConfig) time.Duration {
if hc == nil {
return 0
}
interval := hc.Interval
if interval <= 0 {
interval = defaultDockerHealthcheckInterval
}
timeout := hc.Timeout
if timeout <= 0 {
timeout = defaultDockerHealthcheckTimeout
}
retries := hc.Retries
if retries <= 0 {
retries = defaultDockerHealthcheckRetries
}
// 5s is a buffer to account for scheduling delays.
return hc.StartPeriod + time.Duration(retries)*(interval+timeout) + 5*time.Second
}
+8 -1
View File
@@ -83,10 +83,17 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
} }
} }
// Device reservations are immutable, so we'll need to recreate if any have changed // Device reservations and mappings are immutable, so we'll need to recreate if any have changed
if !reflect.DeepEqual(current.Container.Resources.DeviceReservations, newResources.DeviceReservations) { if !reflect.DeepEqual(current.Container.Resources.DeviceReservations, newResources.DeviceReservations) {
return ContainerNeedsRecreate return ContainerNeedsRecreate
} }
if !reflect.DeepEqual(current.Container.Resources.Devices, newResources.Devices) {
return ContainerNeedsRecreate
}
// Ulimits are immutable, so we'll need to recreate if any have changed.
if !reflect.DeepEqual(current.Container.Resources.Ulimits, newResources.Ulimits) {
return ContainerNeedsRecreate
}
// Check if any mutable properties changed. // Check if any mutable properties changed.
if !current.Caddy.Equals(new.Caddy) { if !current.Caddy.Equals(new.Caddy) {
+160
View File
@@ -47,6 +47,25 @@ func TestEvalContainerSpecChange_ContainerCapDrop(t *testing.T) {
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec)) assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec))
} }
func TestEvalContainerSpecChange_ContainerPidMode(t *testing.T) {
t.Parallel()
currentSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
},
}
newSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
PidMode: "host",
},
}
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(currentSpec, newSpec))
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec))
}
func TestEvalContainerSpecChange_ContainerResources(t *testing.T) { func TestEvalContainerSpecChange_ContainerResources(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1410,6 +1429,68 @@ func TestEvalContainerSpecChange_Volumes(t *testing.T) {
} }
} }
func TestEvalContainerSpecChange_Devices(t *testing.T) {
t.Parallel()
tests := []struct {
name string
current api.ContainerResources
new api.ContainerResources
want ContainerSpecStatus
}{
{
name: "empty",
current: api.ContainerResources{},
new: api.ContainerResources{},
want: ContainerUpToDate,
},
{
name: "identical mapping",
current: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/foo", CgroupPermissions: "rwm"}}},
new: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/foo", CgroupPermissions: "rwm"}}},
want: ContainerUpToDate,
},
{
name: "add mapping",
current: api.ContainerResources{},
new: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/foo", CgroupPermissions: "rwm"}}},
want: ContainerNeedsRecreate,
},
{
name: "remove mapping",
current: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/foo", CgroupPermissions: "rwm"}}},
new: api.ContainerResources{},
want: ContainerNeedsRecreate,
},
{
name: "change mapping path",
current: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/foo", CgroupPermissions: "rwm"}}},
new: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/bar", CgroupPermissions: "rwm"}}},
want: ContainerNeedsRecreate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
currentSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: tt.current,
},
}
newSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: tt.new,
},
}
result := EvalContainerSpecChange(currentSpec, newSpec)
assert.Equal(t, tt.want, result)
})
}
}
func TestEvalContainerSpecChange_DeviceReservations(t *testing.T) { func TestEvalContainerSpecChange_DeviceReservations(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1802,6 +1883,85 @@ func TestEvalContainerSpecChange_DeviceReservations(t *testing.T) {
} }
} }
func TestEvalContainerSpecChange_Ulimits(t *testing.T) {
t.Parallel()
tests := []struct {
name string
current api.ContainerResources
new api.ContainerResources
want ContainerSpecStatus
}{
{
name: "empty",
current: api.ContainerResources{},
new: api.ContainerResources{},
want: ContainerUpToDate,
},
{
name: "identical single ulimit",
current: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
new: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
want: ContainerUpToDate,
},
{
name: "set ulimit",
current: api.ContainerResources{},
new: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
want: ContainerNeedsRecreate,
},
{
name: "remove ulimit",
current: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
new: api.ContainerResources{},
want: ContainerNeedsRecreate,
},
{
name: "change ulimit soft value",
current: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
new: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 30000, Hard: 40000}}},
want: ContainerNeedsRecreate,
},
{
name: "change ulimit hard value",
current: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
new: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 80000}}},
want: ContainerNeedsRecreate,
},
{
name: "add ulimit",
current: api.ContainerResources{Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 20000, Hard: 40000},
}},
new: api.ContainerResources{Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 20000, Hard: 40000},
"nproc": {Soft: 65535, Hard: 65535},
}},
want: ContainerNeedsRecreate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
currentSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: tt.current,
},
}
newSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: tt.new,
},
}
result := EvalContainerSpecChange(currentSpec, newSpec)
assert.Equal(t, tt.want, result)
})
}
}
func TestEvalContainerSpecChange_Mixed(t *testing.T) { func TestEvalContainerSpecChange_Mixed(t *testing.T) {
t.Parallel() t.Parallel()
+239 -10
View File
@@ -4,8 +4,15 @@ 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/scheduler" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
) )
@@ -25,15 +32,237 @@ 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
SequenceOperation // IsNewService indicates this plan creates a new service (first deployment) rather than updating an existing one.
IsNewService bool
// Spec is the desired service spec being deployed.
Spec api.ServiceSpec
operation.SequenceOperation
}
// Format renders the service plan as a styled block with a spec diff and nested container operations.
func (sp *ServicePlan) Format() string {
// Determine service-level operation type and extract the old spec from container operations.
// Assume replace operations precede remove operations (rolling strategy) so the first replace operation
// (if exists) determines the old spec for the diff. Otherwise, fallback to the first remove operation.
var hasRun, hasRemove bool
var oldSpec *api.ServiceSpec
for _, op := range sp.Operations {
switch o := op.(type) {
case *operation.RunContainerOperation:
hasRun = true
case *operation.ReplaceContainerOperation:
if oldSpec == nil {
oldSpec = &o.OldContainer.ServiceSpec
}
case *operation.RemoveContainerOperation:
hasRemove = true
if oldSpec == nil {
oldSpec = &o.Container.ServiceSpec
}
}
}
// Service line modifier and verb.
var modifier, verb string
switch {
case sp.IsNewService:
modifier = tui.BoldGreen.Render("+")
verb = "create"
// TODO: when service removal via a deployment is supported, handle "remove" verb here as well.
default:
modifier = tui.BoldYellow.Render("~")
verb = "update"
}
var out strings.Builder
line := modifier + " " + verb + " service " + tui.NameStyle.Render(sp.ServiceName)
if sp.Spec.Mode == api.ServiceModeGlobal {
line += " " + tui.Faint.Render("(global)")
}
out.WriteString(line)
out.WriteString("\n")
// Build spec diff table: columns are [modifier, attribute, value or change].
// TODO: print diff for all changed attributes, not just image and replicas.
// Consider reusing the logic in EvalContainerSpecChange to return a structured diff.
specTable := table.New().
Border(lipgloss.Border{}).
BorderTop(false).BorderBottom(false).
BorderLeft(false).BorderRight(false).
BorderHeader(false).BorderColumn(false).
StyleFunc(func(row, col int) lipgloss.Style {
switch col {
case 0: // Modifier column.
return tui.Yellow.Width(2)
case 1: // Attribute column.
return tui.Faint.PaddingRight(1)
default:
return lipgloss.NewStyle()
}
})
// Image row.
if oldSpec == nil {
if sp.IsNewService {
specTable.Row("", "image:", tui.FormatImage(sp.Spec.Container.Image, tui.Green))
} else {
specTable.Row("", "image:", tui.FormatImage(sp.Spec.Container.Image, lipgloss.NewStyle()))
}
} else {
mod := ""
if oldSpec.Container.Image != sp.Spec.Container.Image {
mod = "~"
}
specTable.Row(mod, "image:", formatImageDiff(oldSpec.Container.Image, sp.Spec.Container.Image))
}
// Replicas row for replicated services.
if sp.Spec.Mode == api.ServiceModeReplicated {
replicasStr := fmt.Sprintf("%d", sp.Spec.Replicas)
if sp.IsNewService {
if sp.Spec.Replicas > 1 {
specTable.Row("", "replicas:", tui.Green.Render(replicasStr))
}
} else if sp.Spec.Replicas > 1 || hasRun || hasRemove {
mod := ""
if hasRun || hasRemove {
mod = "~"
replicasStr = tui.Green.Render(replicasStr)
}
specTable.Row(mod, "replicas:", replicasStr)
}
}
// Stack " │ " tree prefixes vertically, then join horizontally with the table.
tableStr := specTable.String()
treePrefix := tui.Faint.Render(" │ ")
treeColRows := make([]string, specTable.GetData().Rows())
for i := range treeColRows {
treeColRows[i] = treePrefix
}
treeCol := lipgloss.JoinVertical(lipgloss.Left, treeColRows...)
out.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, treeCol, tableStr))
out.WriteString("\n")
// Blank separator line before container operations.
out.WriteString(tui.Faint.Render(" │"))
out.WriteString("\n")
// Format each container operation.
opsCount := len(sp.Operations)
for i, op := range sp.Operations {
connector := tui.Faint.Render(" ├──")
if i == opsCount-1 {
connector = tui.Faint.Render(" ╰──")
}
out.WriteString(connector + " " + op.Format())
out.WriteString("\n")
}
return out.String()
}
// FormatSummary counts operations in the service plan and renders a styled summary line.
func (sp *ServicePlan) FormatSummary() string {
var createCount, startFirstCount, stopFirstCount, removeCount int
machines := make(map[string]struct{})
for _, op := range sp.Operations {
switch o := op.(type) {
case *operation.RunContainerOperation:
machines[o.MachineID] = struct{}{}
createCount++
case *operation.ReplaceContainerOperation:
machines[o.MachineID] = struct{}{}
if o.Order == api.UpdateOrderStopFirst {
stopFirstCount++
} else {
startFirstCount++
}
case *operation.RemoveContainerOperation:
machines[o.MachineID] = struct{}{}
removeCount++
case *operation.StopContainerOperation:
machines[o.MachineID] = struct{}{}
removeCount++
}
}
var parts []string
if createCount > 0 {
parts = append(parts,
tui.BoldGreen.Render(strconv.Itoa(createCount))+" "+tui.Green.Render("create"))
}
if startFirstCount > 0 {
parts = append(parts,
tui.BoldGreen.Render(strconv.Itoa(startFirstCount))+" "+tui.Green.Render("replace (start-first)"))
}
if stopFirstCount > 0 {
parts = append(parts,
tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)"))
}
if removeCount > 0 {
parts = append(parts,
tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove"))
}
machinesWord := "machines"
if len(machines) == 1 {
machinesWord = "machine"
}
parts = append(parts, fmt.Sprintf("across %s %s", tui.Bold.Render(strconv.Itoa(len(machines))), machinesWord))
sep := " " + tui.Faint.Render("·") + " "
return strings.Join(parts, sep)
}
// formatImageDiff formats the image for display. If oldImage is empty, it formats newImage as a new (green) value.
// Otherwise, it renders the diff between oldImage and newImage.
func formatImageDiff(oldImage, newImage string) string {
if oldImage == "" {
return tui.FormatImage(newImage, tui.Green)
}
if oldImage == newImage {
return tui.FormatImage(newImage, lipgloss.NewStyle())
}
oldRef, _ := reference.ParseDockerRef(oldImage)
newRef, _ := reference.ParseDockerRef(newImage)
// If either uses a digest, show full old → new.
_, oldDigested := oldRef.(reference.Digested)
_, newDigested := newRef.(reference.Digested)
if oldDigested || newDigested {
return tui.FormatImage(oldImage, tui.Red) + " " +
tui.Faint.Render("→") + " " +
tui.FormatImage(newImage, tui.Green)
}
// If repos match and both are tagged, show only tag diff.
oldTagged, oldOk := oldRef.(reference.NamedTagged)
newTagged, newOk := newRef.(reference.NamedTagged)
if oldOk && newOk && reference.FamiliarName(oldRef) == reference.FamiliarName(newRef) {
return reference.FamiliarName(newRef) +
tui.Faint.Render(":") +
tui.Red.Render(oldTagged.Tag()) + " " +
tui.Faint.Render("→") + " " +
tui.Green.Render(newTagged.Tag())
}
// Different repos: full old → new.
return tui.FormatImage(oldImage, tui.Red) + " " +
tui.Faint.Render("→") + " " +
tui.FormatImage(newImage, tui.Green)
} }
// NewDeployment creates a new deployment for the given service specification. // NewDeployment creates a new deployment for the given service specification.
@@ -62,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.
@@ -83,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
@@ -142,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)
-191
View File
@@ -1,191 +0,0 @@
package deploy
import (
"context"
"fmt"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/pkg/api"
)
// Operation represents a single atomic operation in a deployment process.
// Operations can be composed to form complex deployment strategies.
type Operation interface {
// Execute performs the operation using the provided client.
// TODO: Encapsulate the client in the operation as otherwise it gives an impression that different clients
// can be provided. But in reality, the operation is tightly coupled with the client that was used to create it.
Execute(ctx context.Context, cli Client) error
// Format returns a human-readable representation of the operation.
// TODO: get rid of the resolver and assign the required names for formatting in the operation itself.
Format(resolver NameResolver) string
String() string
}
// NameResolver resolves machine and container IDs to their names.
type NameResolver interface {
MachineName(machineID string) string
ContainerName(containerID string) string
}
// TODO: pass api.ServiceContainer to operations to simplify operation formatting in the plan.
// RunContainerOperation creates and starts a new container on a specific machine.
type RunContainerOperation struct {
ServiceID string
Spec api.ServiceSpec
MachineID string
}
func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error {
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
if err != nil {
return fmt.Errorf("create container: %w", err)
}
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
return fmt.Errorf("start container: %w", err)
}
// TODO: wait for the container to become healthy
return nil
}
func (o *RunContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Run container [image=%s]", machineName, o.Spec.Container.Image)
}
func (o *RunContainerOperation) String() string {
return fmt.Sprintf("RunContainerOperation[machine_id=%s service_id=%s image=%s]",
o.MachineID, o.ServiceID, o.Spec.Container.Image)
}
// StopContainerOperation stops a container on a specific machine.
type StopContainerOperation struct {
ServiceID string
ContainerID string
MachineID string
}
func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error {
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
return fmt.Errorf("stop container: %w", err)
}
return nil
}
func (o *StopContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Stop container [id=%s name=%s]", machineName,
o.ContainerID[:12], resolver.ContainerName(o.ContainerID))
}
func (o *StopContainerOperation) String() string {
return fmt.Sprintf("StopContainerOperation[machine_id=%s service_id=%s container_id=%s]",
o.MachineID, o.ServiceID, o.ContainerID)
}
// RemoveContainerOperation stops and removes a container from a specific machine.
type RemoveContainerOperation struct {
MachineID string
Container api.ServiceContainer
}
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) error {
if err := cli.StopContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.StopOptions{}); err != nil {
return fmt.Errorf("stop container: %w", err)
}
if err := cli.RemoveContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.RemoveOptions{
// Remove anonymous volumes created by the container.
RemoveVolumes: true,
}); err != nil {
return fmt.Errorf("remove container: %w", err)
}
return nil
}
func (o *RemoveContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Remove container [id=%s image=%s]",
machineName, o.Container.ShortID(), o.Container.Config.Image)
}
func (o *RemoveContainerOperation) String() string {
return fmt.Sprintf("RemoveContainerOperation[machine_id=%s service_id=%s container_id=%s]",
o.MachineID, o.Container.ServiceID(), o.Container.ID)
}
// CreateVolumeOperation creates a volume on a specific machine.
type CreateVolumeOperation struct {
VolumeSpec api.VolumeSpec
MachineID string
// MachineName is used for formatting the operation output only.
MachineName string
}
func (o *CreateVolumeOperation) Execute(ctx context.Context, cli Client) error {
if o.VolumeSpec.Type != api.VolumeTypeVolume {
return fmt.Errorf("invalid volume type: '%s', expected '%s'", o.VolumeSpec.Type, api.VolumeTypeVolume)
}
opts := volume.CreateOptions{
Name: o.VolumeSpec.DockerVolumeName(),
}
if o.VolumeSpec.VolumeOptions != nil {
if o.VolumeSpec.VolumeOptions.Driver != nil {
opts.Driver = o.VolumeSpec.VolumeOptions.Driver.Name
opts.DriverOpts = o.VolumeSpec.VolumeOptions.Driver.Options
}
opts.Labels = o.VolumeSpec.VolumeOptions.Labels
}
if _, err := cli.CreateVolume(ctx, o.MachineID, opts); err != nil {
return fmt.Errorf("create volume: %w", err)
}
return nil
}
func (o *CreateVolumeOperation) Format(_ NameResolver) string {
return fmt.Sprintf("%s: Create volume [name=%s]", o.MachineName, o.VolumeSpec.DockerVolumeName())
}
func (o *CreateVolumeOperation) String() string {
return fmt.Sprintf("CreateVolumeOperation[machine_id=%s volume=%s]",
o.MachineID, o.VolumeSpec.DockerVolumeName())
}
// SequenceOperation is a composite operation that executes a sequence of operations in order.
type SequenceOperation struct {
Operations []Operation
}
func (o *SequenceOperation) Execute(ctx context.Context, cli Client) error {
for _, op := range o.Operations {
if err := op.Execute(ctx, cli); err != nil {
return err
}
}
return nil
}
func (o *SequenceOperation) Format(resolver NameResolver) string {
ops := make([]string, len(o.Operations))
for i, op := range o.Operations {
ops[i] = "- " + op.Format(resolver)
}
return strings.Join(ops, "\n")
}
func (o *SequenceOperation) String() string {
ops := make([]string, len(o.Operations))
for i, op := range o.Operations {
ops[i] = op.String()
}
return fmt.Sprintf("SequenceOperation[%s]", strings.Join(ops, ", "))
}
+261
View File
@@ -0,0 +1,261 @@
package operation
import (
"context"
"fmt"
"time"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
)
// RunContainerOperation creates and starts a new container on a specific machine.
type RunContainerOperation struct {
ServiceID string
Spec api.ServiceSpec
MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
// SkipHealthMonitor skips the monitoring period and health checks after starting a container.
SkipHealthMonitor bool
}
func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error {
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
if err != nil {
return fmt.Errorf("create container: %w", err)
}
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
return fmt.Errorf("start container: %w", err)
}
if o.SkipHealthMonitor {
return nil
}
opts := api.WaitContainerHealthyOptions{MonitorPeriod: o.Spec.UpdateConfig.MonitorPeriod}
if err = cli.WaitContainerHealthy(ctx, o.ServiceID, resp.ID, opts); err != nil {
return fmt.Errorf("container '%s/%s' failed to become healthy: %w",
o.Spec.Name, stringid.TruncateID(resp.ID), err)
}
return nil
}
func (o *RunContainerOperation) Format() string {
return tui.BoldGreen.Render("+") + " " +
tui.Faint.Render("run container") + " " +
o.Spec.Name + " " +
tui.Faint.Render("on") + " " +
o.MachineName
}
func (o *RunContainerOperation) String() string {
return fmt.Sprintf("RunContainerOperation[machine_id=%s service_id=%s image=%s]",
o.MachineID, o.ServiceID, o.Spec.Container.Image)
}
// StopContainerOperation stops a container on a specific machine.
type StopContainerOperation struct {
ServiceID string
ContainerID string
MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
StopGracePeriod *time.Duration
}
func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error {
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, stopOptions(o.StopGracePeriod)); err != nil {
return fmt.Errorf("stop container: %w", err)
}
return nil
}
func (o *StopContainerOperation) Format() string {
// TODO: pass service name to format the display name consistently with other operations.
displayName := stringid.TruncateID(o.ContainerID)
return tui.BoldRed.Render("-") + " " +
tui.Faint.Render("stop container") + " " +
displayName + " " +
tui.Faint.Render("on") + " " +
o.MachineName
}
func (o *StopContainerOperation) String() string {
return fmt.Sprintf("StopContainerOperation[machine_id=%s service_id=%s container_id=%s]",
o.MachineID, o.ServiceID, o.ContainerID)
}
// RemoveContainerOperation stops and removes a container from a specific machine.
type RemoveContainerOperation struct {
MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
Container api.ServiceContainer
StopGracePeriod *time.Duration
}
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) error {
err := cli.StopContainer(ctx, o.Container.ServiceID(), o.Container.ID, stopOptions(o.StopGracePeriod))
if err != nil {
return fmt.Errorf("stop container: %w", err)
}
if err = cli.RemoveContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.RemoveOptions{
// Remove anonymous volumes created by the container.
RemoveVolumes: true,
}); err != nil {
return fmt.Errorf("remove container: %w", err)
}
return nil
}
func (o *RemoveContainerOperation) Format() string {
displayName := o.Container.ServiceSpec.Name + tui.Faint.Render("/") + o.Container.ShortID()
return tui.BoldRed.Render("-") + " " +
tui.Faint.Render("remove container") + " " +
displayName + " " +
tui.Faint.Render("on") + " " +
o.MachineName
}
func (o *RemoveContainerOperation) String() string {
return fmt.Sprintf("RemoveContainerOperation[machine_id=%s service_id=%s container_id=%s]",
o.MachineID, o.Container.ServiceID(), o.Container.ID)
}
// ReplaceContainerOperation replaces an old container with a new one based on the specified update order.
// For start-first: starts new container, then removes old container.
// For stop-first: stops old container, starts new container, then removes old container.
type ReplaceContainerOperation struct {
ServiceID string
Spec api.ServiceSpec
MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
OldContainer api.ServiceContainer
// Order specifies the update order: "start-first" or "stop-first".
Order string
// SkipHealthMonitor skips the monitoring period and health checks after starting a new container.
SkipHealthMonitor bool
StopGracePeriod *time.Duration
}
func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) error {
stopFirst := o.Order == api.UpdateOrderStopFirst
wasRunning := false
if stopFirst {
// Inspect the old container to remember its running state before stopping.
ctr, err := cli.InspectContainer(ctx, o.ServiceID, o.OldContainer.ID)
if err != nil {
return fmt.Errorf("inspect old container: %w", err)
}
wasRunning = ctr.Container.State.Running
if wasRunning {
err = cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, stopOptions(o.StopGracePeriod))
if err != nil {
return fmt.Errorf("stop old container: %w", err)
}
}
}
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
if err != nil {
return fmt.Errorf("create new container: %w", err)
}
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
return fmt.Errorf("start new container: %w", err)
}
if !o.SkipHealthMonitor {
opts := api.WaitContainerHealthyOptions{MonitorPeriod: o.Spec.UpdateConfig.MonitorPeriod}
if err = cli.WaitContainerHealthy(ctx, o.ServiceID, resp.ID, opts); err != nil {
// New container failed to become healthy. Stop it and roll back to the previous container.
// Don't remove the new stopped container to allow users to inspect logs and state.
// TODO: collect logs from the new container and include in the error message to speed up debugging.
// Use context without progress to not overwrite the container Unhealthy status with Stopped.
ctxWithoutProgress := progress.WithContextWriter(ctx, nil)
_ = cli.StopContainer(ctxWithoutProgress, o.ServiceID, resp.ID, stopOptions(o.StopGracePeriod))
newCtr := fmt.Sprintf("%s/%s", o.Spec.Name, stringid.TruncateID(resp.ID))
healthErr := fmt.Errorf(
"new container '%s' failed to become healthy: %w. "+
"It's stopped and available for inspection. Fetch logs with 'uc logs %s'",
newCtr, err, o.Spec.Name,
)
if stopFirst && wasRunning {
// Restart the old container only if it was running before we stopped it.
oldCtr := fmt.Sprintf("%s/%s", o.OldContainer.ServiceSpec.Name, o.OldContainer.ShortID())
if rollbackErr := cli.StartContainer(ctx, o.ServiceID, o.OldContainer.ID); rollbackErr != nil {
return fmt.Errorf("%w. Rolled back to old container '%s' but failed to restart it: %w",
healthErr, oldCtr, rollbackErr)
}
return fmt.Errorf("%w. Rolled back to old container '%s'", healthErr, oldCtr)
}
return healthErr
}
}
// For start-first, we need to stop before removing.
// For stop-first, the container is already stopped.
if !stopFirst {
// TODO: the new container is propagated to Caddy upstreams through the cluster store asynchronously.
// There still might be a brief downtime (for a 1 replica service) when Caddy doesn't know about
// the new container but we're stopping the old container. We should somehow ensure Caddy is updated
// with the new container before we stop the old one to avoid this downtime.
if err = cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, stopOptions(o.StopGracePeriod)); err != nil {
return fmt.Errorf("stop old container: %w", err)
}
}
if err = cli.RemoveContainer(ctx, o.ServiceID, o.OldContainer.ID, container.RemoveOptions{
RemoveVolumes: true,
}); err != nil {
return fmt.Errorf("remove old container: %w", err)
}
return nil
}
func (o *ReplaceContainerOperation) Format() string {
displayName := o.Spec.Name + tui.Faint.Render("/") + o.OldContainer.ShortID()
if o.Order == api.UpdateOrderStopFirst {
return tui.BoldYellow.Render("-") + tui.Yellow.Render("/") + tui.BoldYellow.Render("+") + " " +
tui.Faint.Render("replace container") + " " +
displayName + " " +
tui.Faint.Render("on") + " " +
o.MachineName + " " +
tui.Yellow.Render("(stop-first)")
}
return tui.BoldGreen.Render("+") + tui.Green.Render("/") + tui.BoldGreen.Render("-") + " " +
tui.Faint.Render("replace container") + " " +
displayName + " " +
tui.Faint.Render("on") + " " +
o.MachineName
}
func (o *ReplaceContainerOperation) String() string {
return fmt.Sprintf("ReplaceContainerOperation[machine_id=%s service_id=%s old_container_id=%s order=%s]",
o.MachineID, o.ServiceID, o.OldContainer.ID, o.Order)
}
// stopOptions converts a stop grace period duration to Docker container stop options.
func stopOptions(gracePeriod *time.Duration) container.StopOptions {
if gracePeriod == nil {
return container.StopOptions{}
}
t := int(gracePeriod.Seconds())
return container.StopOptions{Timeout: &t}
}
+23
View File
@@ -0,0 +1,23 @@
package operation
import (
"context"
"github.com/psviderski/uncloud/pkg/api"
)
// Operation represents a single atomic operation in a deployment process.
// Operations can be composed to form complex deployment strategies.
type Operation interface {
// Execute performs the operation using the provided client.
Execute(ctx context.Context, cli Client) error
// Format returns a human-readable representation of the operation.
Format() string
String() string
}
// Client defines the interface required to execute deployment operations.
type Client interface {
api.ContainerClient
api.VolumeClient
}
+39
View File
@@ -0,0 +1,39 @@
package operation
import (
"context"
"fmt"
"strings"
)
// SequenceOperation is a composite operation that executes a sequence of operations in order.
type SequenceOperation struct {
Operations []Operation
}
func (o *SequenceOperation) Execute(ctx context.Context, cli Client) error {
for _, op := range o.Operations {
if err := op.Execute(ctx, cli); err != nil {
return err
}
}
return nil
}
func (o *SequenceOperation) Format() string {
lines := make([]string, len(o.Operations))
for i, op := range o.Operations {
lines[i] = op.Format()
}
return strings.Join(lines, "\n")
}
func (o *SequenceOperation) String() string {
ops := make([]string, len(o.Operations))
for i, op := range o.Operations {
ops[i] = op.String()
}
return fmt.Sprintf("SequenceOperation[%s]", strings.Join(ops, ", "))
}
+54
View File
@@ -0,0 +1,54 @@
package operation
import (
"context"
"fmt"
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
)
// CreateVolumeOperation creates a volume on a specific machine.
type CreateVolumeOperation struct {
VolumeSpec api.VolumeSpec
MachineID string
// MachineName is used for formatting the operation as part of the deployment plan.
MachineName string
}
func (o *CreateVolumeOperation) Execute(ctx context.Context, cli Client) error {
if o.VolumeSpec.Type != api.VolumeTypeVolume {
return fmt.Errorf("invalid volume type: '%s', expected '%s'", o.VolumeSpec.Type, api.VolumeTypeVolume)
}
opts := volume.CreateOptions{
Name: o.VolumeSpec.DockerVolumeName(),
}
if o.VolumeSpec.VolumeOptions != nil {
if o.VolumeSpec.VolumeOptions.Driver != nil {
opts.Driver = o.VolumeSpec.VolumeOptions.Driver.Name
opts.DriverOpts = o.VolumeSpec.VolumeOptions.Driver.Options
}
opts.Labels = o.VolumeSpec.VolumeOptions.Labels
}
if _, err := cli.CreateVolume(ctx, o.MachineID, opts); err != nil {
return fmt.Errorf("create volume: %w", err)
}
return nil
}
func (o *CreateVolumeOperation) Format() string {
return fmt.Sprintf("%s create volume %s %s %s",
tui.BoldGreen.Render("+"),
tui.NameStyle.Render(o.VolumeSpec.DockerVolumeName()),
tui.Faint.Render("on"),
o.MachineName)
}
func (o *CreateVolumeOperation) String() string {
return fmt.Sprintf("CreateVolumeOperation[machine_id=%s volume=%s]",
o.MachineID, o.VolumeSpec.DockerVolumeName())
}
+121 -28
View File
@@ -11,6 +11,7 @@ import (
// VolumeScheduler determines what missing volumes should be created and where for a multi-service deployment. // VolumeScheduler determines what missing volumes should be created and where for a multi-service deployment.
// It satisfies the following constraints: // It satisfies the following constraints:
// - Volumes used by global services will be created on all eligible machines.
// - Services that share a volume must be placed on the same machine where the volume is located. // - Services that share a volume must be placed on the same machine where the volume is located.
// If the volume is located on multiple machines, services can be placed on any of them. // If the volume is located on multiple machines, services can be placed on any of them.
// - Services must respect their individual placement constraints. // - Services must respect their individual placement constraints.
@@ -128,12 +129,20 @@ func (s *VolumeScheduler) Schedule() (map[string][]api.VolumeSpec, error) {
serviceEligibleMachines[spec.Name] = machineIDs serviceEligibleMachines[spec.Name] = machineIDs
} }
// For each volume that exists on any machine(s) (which shouldn't be created), intersect each service's // For each volume that exists on any machine(s), intersect each non-global service's
// eligible machines that use the volume with the machines the volume is located on. // eligible machines that use the volume with the machines the volume is located on.
// Global services skip this constraint as they need the volume on ALL eligible machines,
// and the volume will be created on machines that don't have it.
// //
// Service name -> list of processed volume names (quoted) to format the error message. // Service name -> list of processed volume names (quoted) to format the error message.
quotedServiceVolumes := make(map[string][]string) quotedServiceVolumes := make(map[string][]string)
for volumeName, volumeMachines := range s.existingVolumeMachines { for volumeName, volumeMachines := range s.existingVolumeMachines {
// Skip constraint narrowing for global services - they don't need to be constrained
// to machines that already have the volume.
if s.isVolumeForGlobalService(volumeName) {
continue
}
for _, serviceName := range s.volumeServices[volumeName] { for _, serviceName := range s.volumeServices[volumeName] {
quotedServiceVolumes[serviceName] = append(quotedServiceVolumes[serviceName], quotedServiceVolumes[serviceName] = append(quotedServiceVolumes[serviceName],
fmt.Sprintf("'%s'", volumeName)) fmt.Sprintf("'%s'", volumeName))
@@ -147,52 +156,97 @@ func (s *VolumeScheduler) Schedule() (map[string][]api.VolumeSpec, error) {
} }
} }
// Skip constraints propagation for volumes that already exist on machines as the propagation only works // Skip constraints propagation for:
// for missing volumes. // 1. Volumes that already exist on machines (for replicated services) as the propagation only works
// for missing volumes. Global service volumes are NOT marked as placed here since they still need
// to be scheduled on machines that don't have them.
// 2. Volumes only used by global services - these need UNION of eligible machines, not intersection.
placedVolumes := make(map[string]struct{}) placedVolumes := make(map[string]struct{})
for volumeName := range s.existingVolumeMachines { for volumeName := range s.existingVolumeMachines {
placedVolumes[volumeName] = struct{}{} if !s.isVolumeForGlobalService(volumeName) {
placedVolumes[volumeName] = struct{}{}
}
}
// Skip constraint propagation for volumes used by global services.
// Also check for invalid configuration: volume shared between global and replicated services.
for volumeName := range s.volumeSpecs {
if s.isVolumeSharedBetweenGlobalAndReplicated(volumeName) {
return nil, fmt.Errorf("volume '%s' cannot be shared between global and replicated services: "+
"global services require the volume on all machines while replicated services require "+
"co-location with the volume", volumeName)
}
if s.isVolumeForGlobalService(volumeName) {
placedVolumes[volumeName] = struct{}{}
}
} }
if err := s.propagateConstraintsUntilConvergence(serviceEligibleMachines, placedVolumes); err != nil { if err := s.propagateConstraintsUntilConvergence(serviceEligibleMachines, placedVolumes); err != nil {
return nil, err return nil, err
} }
// Schedule each missing volume on one of its eligible machines. // Schedule each missing volume on eligible machines.
// For global services: schedule on ALL eligible machines that don't already have the volume.
// For replicated services: schedule on ONE eligible machine (skip if volume exists anywhere).
scheduledVolumes := make(map[string][]api.VolumeSpec) scheduledVolumes := make(map[string][]api.VolumeSpec)
for missingVolumeName, missingVolumeSpec := range s.volumeSpecs { for volumeName, volumeSpec := range s.volumeSpecs {
// Skip volumes that already exist on machines. existingMachines := s.existingVolumeMachines[volumeName]
if _, ok := s.existingVolumeMachines[missingVolumeName]; ok { serviceNames := s.volumeServices[volumeName]
continue
}
serviceNames := s.volumeServices[missingVolumeName]
if len(serviceNames) == 0 { if len(serviceNames) == 0 {
return nil, fmt.Errorf("bug detected: no services using volume '%s'", missingVolumeName) return nil, fmt.Errorf("bug detected: no services using volume '%s'", volumeName)
} }
// Get the current eligible machines (any service using the volume will have the same set after convergence). // Get the eligible machines for this volume.
eligibleMachines := serviceEligibleMachines[serviceNames[0]] // For volumes used by global services: compute UNION of all services' eligible machines.
// For other volumes: any service will have the same set after constraint convergence.
var eligibleMachines mapset.Set[string]
if s.isVolumeForGlobalService(volumeName) {
// Compute union of eligible machines for all global services using this volume.
eligibleMachines = mapset.NewSet[string]()
for _, serviceName := range serviceNames {
eligibleMachines = eligibleMachines.Union(serviceEligibleMachines[serviceName])
}
} else {
eligibleMachines = serviceEligibleMachines[serviceNames[0]]
}
if eligibleMachines.Cardinality() == 0 { if eligibleMachines.Cardinality() == 0 {
return nil, fmt.Errorf("bug detected: no eligible machines for volume '%s'", missingVolumeName) return nil, fmt.Errorf("bug detected: no eligible machines for volume '%s'", volumeName)
} }
// Choose the first machine in the sorted eligible machines to schedule the volume on.
// Sort the eligible machines to ensure deterministic behavior. // Sort the eligible machines to ensure deterministic behavior.
sortedEligibleMachines := eligibleMachines.ToSlice() sortedEligibleMachines := eligibleMachines.ToSlice()
slices.Sort(sortedEligibleMachines) slices.Sort(sortedEligibleMachines)
machineID := sortedEligibleMachines[0]
// Update constraints for all services that use this volume to be placed on the selected machine.
for _, serviceName := range serviceNames {
serviceEligibleMachines[serviceName] = mapset.NewSet(machineID)
}
placedVolumes[missingVolumeName] = struct{}{}
scheduledVolumes[machineID] = append(scheduledVolumes[machineID], missingVolumeSpec)
// Propagate the updated constraints. if s.isVolumeForGlobalService(volumeName) {
if err := s.propagateConstraintsUntilConvergence(serviceEligibleMachines, placedVolumes); err != nil { // Global service: schedule volume on eligible machines that don't already have it.
return nil, fmt.Errorf("unexpected error while propagating constraints after "+ for _, machineID := range sortedEligibleMachines {
"scheduling volume '%s' on machine '%s': %w", missingVolumeName, machineID, err) if existingMachines != nil && existingMachines.Contains(machineID) {
// Volume already exists on this machine, skip it.
continue
}
scheduledVolumes[machineID] = append(scheduledVolumes[machineID], volumeSpec)
}
// Mark volume as placed - no constraint propagation needed since volume will be on all machines.
placedVolumes[volumeName] = struct{}{}
} else {
// Replicated service: skip if volume already exists on any machine (services will use that location).
if existingMachines != nil && existingMachines.Cardinality() > 0 {
continue
}
// Schedule volume on ONE machine (first in sorted order).
machineID := sortedEligibleMachines[0]
// Update constraints for all services that use this volume to be placed on the selected machine.
for _, serviceName := range serviceNames {
serviceEligibleMachines[serviceName] = mapset.NewSet(machineID)
}
placedVolumes[volumeName] = struct{}{}
scheduledVolumes[machineID] = append(scheduledVolumes[machineID], volumeSpec)
// Propagate the updated constraints.
if err := s.propagateConstraintsUntilConvergence(serviceEligibleMachines, placedVolumes); err != nil {
return nil, fmt.Errorf("unexpected error while propagating constraints after "+
"scheduling volume '%s' on machine '%s': %w", volumeName, machineID, err)
}
} }
} }
@@ -298,3 +352,42 @@ func (s *VolumeScheduler) propagateConstraintsUntilConvergence(
return nil return nil
} }
// isVolumeForGlobalService returns true if any service using this volume is a global service.
func (s *VolumeScheduler) isVolumeForGlobalService(volumeName string) bool {
serviceNames := s.volumeServices[volumeName]
for _, serviceName := range serviceNames {
for _, spec := range s.serviceSpecs {
if spec.Name == serviceName && spec.Mode == api.ServiceModeGlobal {
return true
}
}
}
return false
}
// isVolumeSharedBetweenGlobalAndReplicated returns true if a volume is used by both
// global and replicated services, which is an invalid configuration.
func (s *VolumeScheduler) isVolumeSharedBetweenGlobalAndReplicated(volumeName string) bool {
serviceNames := s.volumeServices[volumeName]
hasGlobal := false
hasReplicated := false
for _, serviceName := range serviceNames {
for _, spec := range s.serviceSpecs {
if spec.Name == serviceName {
mode := spec.Mode
if mode == "" {
mode = api.ServiceModeReplicated
}
if mode == api.ServiceModeGlobal {
hasGlobal = true
} else {
hasReplicated = true
}
}
}
}
return hasGlobal && hasReplicated
}
+390
View File
@@ -802,6 +802,396 @@ func TestVolumeScheduler_Schedule(t *testing.T) {
}, },
}, },
}, },
{
name: "global service with missing volume schedules on all machines",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
{
Info: &pb.MachineInfo{
Id: "machine3",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service",
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "vol1",
ContainerPath: "/data",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
want: map[string][]api.VolumeSpec{
"machine1": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
"machine2": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
"machine3": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
{
name: "global service with volume on some machines schedules remaining",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
Volumes: []volume.Volume{
{
Name: "vol1",
},
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
{
Info: &pb.MachineInfo{
Id: "machine3",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service",
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "vol1",
ContainerPath: "/data",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
want: map[string][]api.VolumeSpec{
"machine2": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
"machine3": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
{
name: "global service with placement constraint",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
{
Info: &pb.MachineInfo{
Id: "machine3",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service",
Mode: api.ServiceModeGlobal,
Placement: api.Placement{
Machines: []string{"machine1", "machine3"},
},
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "vol1",
ContainerPath: "/data",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
want: map[string][]api.VolumeSpec{
"machine1": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
"machine3": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
{
name: "volume shared between global and replicated fails",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service",
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
{
Name: "replicated-service",
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
},
wantErr: "volume 'shared-vol' cannot be shared between global and replicated services",
},
{
name: "multiple global services sharing same volume",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service-1",
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data1",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
{
Name: "global-service-2",
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data2",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
},
want: map[string][]api.VolumeSpec{
"machine1": {
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
"machine2": {
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
},
{
name: "multiple global services sharing same volume with different placement constraints",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
{
Info: &pb.MachineInfo{
Id: "machine3",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service-1",
Mode: api.ServiceModeGlobal,
Placement: api.Placement{
Machines: []string{"machine1", "machine2"},
},
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data1",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
{
Name: "global-service-2",
Mode: api.ServiceModeGlobal,
Placement: api.Placement{
Machines: []string{"machine2", "machine3"},
},
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data2",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
},
want: map[string][]api.VolumeSpec{
"machine1": {
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
"machine2": {
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
"machine3": {
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
},
} }
for _, tt := range tests { for _, tt := range tests {
+176 -79
View File
@@ -8,6 +8,7 @@ import (
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
) )
@@ -19,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
@@ -28,6 +29,8 @@ type RollingStrategy struct {
// ForceRecreate indicates whether all containers should be recreated during the deployment, // ForceRecreate indicates whether all containers should be recreated during the deployment,
// regardless of whether their specifications have changed. // regardless of whether their specifications have changed.
ForceRecreate bool ForceRecreate bool
// SkipHealthMonitor skips the monitoring period and health checks for faster emergency deployments.
SkipHealthMonitor bool
// state is the current and planned state of the cluster used for scheduling decisions. // state is the current and planned state of the cluster used for scheduling decisions.
state *scheduler.ClusterState state *scheduler.ClusterState
@@ -37,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
@@ -50,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)
} }
} }
@@ -58,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()
@@ -144,10 +153,12 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
if len(containers) == 0 { if len(containers) == 0 {
// No more existing containers on this machine, create a new one. // No more existing containers on this machine, create a new one.
plan.Operations = append(plan.Operations, &RunContainerOperation{ plan.Operations = append(plan.Operations, &operation.RunContainerOperation{
ServiceID: plan.ServiceID, ServiceID: plan.ServiceID,
Spec: spec, Spec: spec,
MachineID: m.Id, MachineID: m.Id,
MachineName: m.Name,
SkipHealthMonitor: s.SkipHealthMonitor,
}) })
continue continue
} }
@@ -160,38 +171,30 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
continue continue
} }
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported. // TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
conflictingPorts, portsErr := ctr.ConflictingServicePorts(spec.Ports)
if portsErr != nil || len(conflictingPorts) > 0 {
// Stop the malformed container or the container with conflicting ports.
plan.Operations = append(plan.Operations, &StopContainerOperation{
ServiceID: plan.ServiceID,
ContainerID: ctr.ID,
MachineID: m.Id,
})
}
} }
// Run a new container. // Replace the old container with a new one.
plan.Operations = append(plan.Operations, &RunContainerOperation{ order := determineUpdateOrder(ctr, spec)
ServiceID: plan.ServiceID, plan.Operations = append(plan.Operations, &operation.ReplaceContainerOperation{
Spec: spec, ServiceID: plan.ServiceID,
MachineID: m.Id, Spec: spec,
}) MachineID: m.Id,
MachineName: m.Name,
// Remove the old container. OldContainer: ctr,
plan.Operations = append(plan.Operations, &RemoveContainerOperation{ Order: order,
MachineID: m.Id, SkipHealthMonitor: s.SkipHealthMonitor,
Container: ctr, StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
// Remove any remaining containers that are not needed. // Remove any remaining containers that are not needed.
for mid, containers := range containersOnMachine { for mid, containers := range containersOnMachine {
for _, c := range containers { for _, c := range containers {
plan.Operations = append(plan.Operations, &RemoveContainerOperation{ plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
MachineID: mid, MachineID: mid,
Container: c, MachineName: machineNames[mid],
Container: c,
StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
} }
@@ -204,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.
@@ -228,7 +237,14 @@ 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(containers, spec, plan.ServiceID, m.Info.Id, s.ForceRecreate) ops, err := reconcileGlobalContainer(
containers,
spec,
plan.ServiceID,
m.Info,
s.ForceRecreate,
s.SkipHealthMonitor,
)
if err != nil { if err != nil {
return plan, err return plan, err
} }
@@ -240,9 +256,11 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
// Remove any remaining containers on machines that don't match the new placement constraints. // Remove any remaining containers on machines that don't match the new placement constraints.
for _, containers := range containersOnMachine { for _, containers := range containersOnMachine {
for _, c := range containers { for _, c := range containers {
plan.Operations = append(plan.Operations, &RemoveContainerOperation{ plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
MachineID: c.MachineID, MachineID: c.MachineID,
Container: c.Container, MachineName: machineNames[c.MachineID],
Container: c.Container,
StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
} }
@@ -254,16 +272,22 @@ 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, forceRecreate bool, containers []api.MachineServiceContainer,
) ([]Operation, error) { spec api.ServiceSpec,
var ops []Operation serviceID string,
machine *pb.MachineInfo,
forceRecreate, skipHealthCheck bool,
) ([]operation.Operation, error) {
var ops []operation.Operation
if len(containers) == 0 { if len(containers) == 0 {
// No containers on this machine, create a new one. // No containers on this machine, create a new one.
ops = append(ops, &RunContainerOperation{ ops = append(ops, &operation.RunContainerOperation{
ServiceID: serviceID, ServiceID: serviceID,
Spec: spec, Spec: spec,
MachineID: machineID, MachineID: machine.Id,
MachineName: machine.Name,
SkipHealthMonitor: skipHealthCheck,
}) })
return ops, nil return ops, nil
} }
@@ -290,9 +314,11 @@ func reconcileGlobalContainer(
if i == j { if i == j {
continue continue
} }
ops = append(ops, &RemoveContainerOperation{ ops = append(ops, &operation.RemoveContainerOperation{
MachineID: old.MachineID, MachineID: old.MachineID,
Container: old.Container, MachineName: machine.Name,
Container: old.Container,
StopGracePeriod: spec.Container.StopGracePeriod,
}) })
} }
break break
@@ -304,52 +330,123 @@ func reconcileGlobalContainer(
} }
// The machine has containers but none of them match the new spec. // The machine has containers but none of them match the new spec.
// Stop the old running containers that have conflicting ports with the new spec before running a new one. // Find the first running container to replace (there should typically be only one).
for _, c := range containers { var containerToReplace *api.MachineServiceContainer
for i, c := range containers {
if c.Container.State.Running { if c.Container.State.Running {
conflictingPorts, err := c.Container.ConflictingServicePorts(spec.Ports) containerToReplace = &containers[i]
if err != nil { break
return nil, fmt.Errorf("check conflicting ports: %w", err)
}
if len(conflictingPorts) > 0 {
// Stop the running container with conflicting ports.
ops = append(ops, &StopContainerOperation{
ServiceID: serviceID,
ContainerID: c.Container.ID,
MachineID: c.MachineID,
})
}
} }
} }
// Run a new container. if containerToReplace != nil {
ops = append(ops, &RunContainerOperation{ // Stop any other running containers that have conflicting ports before replacing the container.
ServiceID: serviceID, // This handles the edge case where multiple running containers exist (due to bugs or interrupted deployments)
Spec: spec, // and more than one has ports that conflict with the new spec.
MachineID: machineID, for _, c := range containers {
}) if c.Container.ID == containerToReplace.Container.ID || !c.Container.State.Running {
continue
}
conflictingPorts, err := c.Container.ConflictingServicePorts(spec.Ports)
if err != nil || len(conflictingPorts) > 0 {
ops = append(ops, &operation.StopContainerOperation{
ServiceID: serviceID,
ContainerID: c.Container.ID,
MachineID: machine.Id,
MachineName: machine.Name,
StopGracePeriod: spec.Container.StopGracePeriod,
})
}
}
// Remove the old containers. // Replace the running container with a new one.
for _, c := range containers { order := determineUpdateOrder(containerToReplace.Container, spec)
ops = append(ops, &RemoveContainerOperation{ ops = append(ops, &operation.ReplaceContainerOperation{
MachineID: c.MachineID, ServiceID: serviceID,
Container: c.Container, Spec: spec,
MachineID: machine.Id,
MachineName: machine.Name,
OldContainer: containerToReplace.Container,
Order: order,
SkipHealthMonitor: skipHealthCheck,
StopGracePeriod: spec.Container.StopGracePeriod,
}) })
// Remove any other containers (there shouldn't be any in normal operation).
for _, c := range containers {
if c.Container.ID == containerToReplace.Container.ID {
continue
}
ops = append(ops, &operation.RemoveContainerOperation{
MachineID: c.MachineID,
MachineName: machine.Name,
Container: c.Container,
StopGracePeriod: spec.Container.StopGracePeriod,
})
}
} else {
// No running containers, create a new one and remove all stopped containers.
ops = append(ops, &operation.RunContainerOperation{
ServiceID: serviceID,
Spec: spec,
MachineID: machine.Id,
MachineName: machine.Name,
SkipHealthMonitor: skipHealthCheck,
})
for _, c := range containers {
ops = append(ops, &operation.RemoveContainerOperation{
MachineID: c.MachineID,
MachineName: machine.Name,
Container: c.Container,
StopGracePeriod: spec.Container.StopGracePeriod,
})
}
} }
return ops, nil return ops, nil
} }
// newEmptyPlan creates a new empty plan for a service deployment with initialised service ID and name. // determineUpdateOrder determines the update order for replacing a container based on the service spec
func newEmptyPlan(svc *api.Service, spec api.ServiceSpec) (Plan, error) { // and current container state. The order can be explicitly set in UpdateConfig, or automatically determined:
var plan Plan // - If the user explicitly set order, respect it
// - Services with port conflicts require stop-first (ports must be freed first)
// - Single-replica services with data volumes default to stop-first (prevents data corruption)
// - Multi-replica services use start-first (concurrent access already happening)
// - All other services default to start-first (minimizes downtime)
func determineUpdateOrder(oldContainer api.ServiceContainer, spec api.ServiceSpec) string {
// User explicitly set order - respect it
if spec.UpdateConfig.Order != "" {
return spec.UpdateConfig.Order
}
// Port conflicts require stop-first
conflictingPorts, err := oldContainer.ConflictingServicePorts(spec.Ports)
if err != nil || len(conflictingPorts) > 0 {
return api.UpdateOrderStopFirst
}
// Single-replica services with data volumes default to stop-first to prevent data corruption.
// Multi-replica services already have concurrent access, so start-first is safe.
if spec.Replicas <= 1 && len(spec.MountedDockerVolumes()) > 0 {
return api.UpdateOrderStopFirst
}
// Default: start-first for minimal downtime
return api.UpdateOrderStartFirst
}
// newEmptyServicePlan creates a new empty plan for a service deployment with initialised service ID and name.
func newEmptyServicePlan(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
plan := ServicePlan{
Spec: spec,
}
// Generate a new service ID for the initial service deployment if it doesn't exist yet. // 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 {

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