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
Pasha Sviderski 1caa5ccf0e docs: generate CLI reference docs for wg commands 2026-01-10 18:37:40 +11:00
Pasha Sviderski 76affeb6b2 chore: minor formatting for 'wg show' command, enrich Unimplemented error with >=0.16 requirement 2026-01-10 18:36:56 +11:00
Justin BradfordandGitHub a945291371 feat: Add uc wg show command to inspect a machine's uncloud wireguard network (#161)
* feat: Add `uc wg show` command to inspect a machine's uncloud wireguard network (draft / work in progress)

* Compile proto files with `make proto-mise`

* Fix lint errors

* Output peers in tabwriter table

* Lookup machine name for wireguard peer public key

* Add --machine flag to `uc wg show` to proxy call to specific machine

* Small refactor to move wg show logic out into a separate function
2026-01-10 16:49:35 +11:00
06c3fba96e feat: add support for sysctls compose key (#239)
* feat: add support for `sysctls` compose key

* lint

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-01-10 15:53:56 +11:00
Pasha Sviderski 7e739dd2f0 docs: update compose matrix 'build' supported, link capabilities man page 2026-01-10 15:27:42 +11:00
Andrey ViktorovandGitHub 6c2f85d38a feat: add support for cap_add and cap_drop compose keys (#238) 2026-01-10 15:13:03 +11:00
Pasha Sviderski 1ea1d15845 docs: update README subtitle 2026-01-04 16:22:46 +10:00
Anton OvchinnikovandGitHub 5e0d4d0f05 build: Generate CLI docs in GHA (#236)
* build: Generate CLI docs in GHA
* fix: Move no-tty logic outside cobra definitions
2026-01-02 10:23:42 +10:00
Anton OvchinnikovandGitHub 9a1d2e0d46 fix: Equals and Clone for configs (#235) 2025-12-30 20:32:19 +10:00
Anton Ovchinnikov 878f96a4b7 Revert "build: Generate CLI docs in GHA"
This reverts commit 9efaab2beb.
2025-12-30 01:51:31 +01:00
Anton Ovchinnikov 9efaab2beb build: Generate CLI docs in GHA 2025-12-30 01:22:56 +01:00
Anton OvchinnikovandGitHub 2c34ba7eff fix(configs): Create non-existent parent directories automatically (#233) 2025-12-29 11:38:06 +01:00
Anton Ovchinnikov 57e2e22266 docs: Add missing docs for uc start/stop 2025-12-28 17:01:01 +01:00
Anton OvchinnikovandGitHub 4ec5c15254 docs: Link to iximiuz Labs tutorials (#230) 2025-12-27 15:33:12 +10:00
Anton Ovchinnikov 33a3683196 chore: Regenerate CLI docs 2025-12-26 12:23:59 +01:00
Pasha Sviderski 4be1ea3506 fix: machine name for failed machines in 'uc ps' 2025-12-23 19:49:21 +10:00
Pasha Sviderski f86e63c851 chore: reduce the default gRPC connection timeout to proxied machines 20->10s 2025-12-23 19:48:57 +10:00
Pasha Sviderski f6cf73190f chore: wait for FailedPrecondition in addition to Unavailable for backward compatibility 2025-12-23 19:48:18 +10:00
Pasha Sviderski 5308c87651 chore: update 'uc machine init/add' to wait for cluster readiness, confirm caddy deployment on added machine 2025-12-23 19:47:26 +10:00
Pasha Sviderski 9bd5ffcd2d chore: update ucind cluster to wait for initial store sync and cluster readiness 2025-12-23 19:02:04 +10:00
Pasha Sviderski 8d8acd5410 chore: wait for the initial store sync and cluster components before serving cluster gRPC requests 2025-12-23 19:00:09 +10:00
Pasha Sviderski 4d97c30a9c chore: enable gRPC auto retries for transient Unavailable failures up to ~8s 2025-12-23 18:54:08 +10:00
Pasha Sviderski aa71ab0220 fix: e2e caddy test after changing the config header 2025-12-22 21:22:56 +10:00
Pasha Sviderski 2345ee9856 docs: improve Caddyfile header and note about misconfigured caddy service, minor docs update 2025-12-22 16:31:15 +10:00
Justin BradfordandGitHub 65e9cdd444 refactor: use WaitGroup.Go to replace wg.Add(1)/go/wg.Done() boilerplate (#222)
https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_waitgroup
2025-12-19 21:07:29 +10:00
Pasha Sviderski 85a2615db1 chore: add 'ctx conn' alias for 'ctx connection' command 2025-12-19 20:53:30 +10:00
Pasha Sviderski 777615bc56 chore: add TODO to remove extra checks after upgrading clusters to perform store sync 2025-12-19 20:52:46 +10:00
Pasha Sviderski bac34ebd0c chore: save machine state file atomically 2025-12-19 20:12:46 +10:00
Pasha Sviderski b2322e1c49 fix: WireGuard peers misconfiguration on joined machine by waiting for the initial store sync 2025-12-19 19:52:13 +10:00
Pasha Sviderski 436671457d chore: pass current store DB version when adding new machine to cluster 2025-12-19 18:51:49 +10:00
Pasha Sviderski fc847093d2 chore: new InspectMachine gRPC method that to return store DB version (lamport time) 2025-12-19 16:29:14 +10:00
Pasha Sviderski d2a7af744d fix: uncloud daemon crash when listing partially replicated container records 2025-12-18 16:32:26 +10:00
Pasha Sviderski 5acf557675 chore: add --yes flag to 'uc machine init/add' to auto-confirm machine reset 2025-12-17 17:36:54 +10:00
Pasha Sviderski 0b32cd2095 chore: upgrade unregistry 0.3.1 -> 0.4.1 with a fix for pushing big images 2025-12-16 20:08:40 +10:00
Pasha Sviderski 425b974331 fix: panic when initialising new cluster with --connect 2025-12-16 11:28:11 +10:00
Pasha Sviderski db09dec855 chore: add more tests for MatchesDockerVolume and update error message when volume spec doesn't match 2025-12-15 18:12:12 +10:00
Justin BradfordandGitHub 20cdcdf917 fix: Set volume driver name to "local" if not specified (#219) 2025-12-15 17:50:36 +10:00
Pasha Sviderski 796363f23c fix: TestCollectContainers unit tests 2025-12-12 21:02:22 +10:00
Pasha Sviderski cb0df2169f feat: add IP ADDRESS column to 'uc ps' and 'uc inspect' 2025-12-12 20:23:43 +10:00
Pasha Sviderski 95afc7f289 chore: sort 'uc inspect' containers by CREATED 2025-12-12 20:13:02 +10:00
Pasha Sviderski bc65c73c51 chore: add CREATED column to 'uc ps', fallback sorting by CREATED 2025-12-12 20:01:49 +10:00
Pasha Sviderski 596bc561b8 chore: 'uc stop' - make --timeout default explicit, update flag and long descriptions 2025-12-12 19:16:17 +10:00
Pasha Sviderski f215fae6c7 chore: group deploy and service management commands in uc --help 2025-12-12 18:52:08 +10:00
Pasha Sviderski 5707c37b63 docs: replace managed DNS domain: cluster.uncloud.run -> uncld.dev 2025-12-12 18:21:11 +10:00
Pasha Sviderski 5780d3a8a4 docs: regenereate CLI reference 2025-12-10 20:48:28 +10:00
Pasha Sviderski ac18ff3e8b docs: add examples section to uc logs command 2025-12-10 20:47:42 +10:00
a9a720f3c3 feat: Add uc service start/stop commands (#195)
* feat: Add `uc service start/stop` commands

* Add nolint:dupl for service start/stop commands

Co-authored-by: Pasha Sviderski <me@psviderski.name>

* Add --signal and --timeout options to uc service stop

* Check whether service stop --timeout flag was used to set options properly

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
2025-12-09 15:58:09 +10:00
4b4b721d49 feat(uc/caddy): expose 443/udp port for HTTP3 (#204)
* feat(caddy): expose 443/udp port for HTTP3

* expose udp port 443 for caddy to support new HTTP3

* chore: Add comment for HTTP/3 (QUIC) port configuration

Added a comment to clarify the purpose of the UDP port configuration.

---------

Co-authored-by: Anton Ovchinnikov <anton@tonyo.info>
2025-12-08 19:21:30 +10:00
Justin BradfordandGitHub 6373d4097c fix: volume driver options are not used if driver if not specified [bug #210] (#211) 2025-12-08 15:58:43 +10:00
Pasha Sviderski dfe12ca644 docs: add #1 repo of the day badge to README 2025-12-08 12:23:50 +10:00
Pasha Sviderski 2ef8a6fd20 docs: update landing value section 2025-12-05 00:28:37 +10:00
Pasha Sviderski 27cfe1c4f3 feat: 'uc logs' without argements streams logs for services in the local Compose file 2025-12-04 15:40:24 +10:00
Pasha Sviderski ebd4622592 lint 2025-12-04 14:26:25 +10:00
Pasha Sviderski 4fa30ab8cb test: e2e test for client.ServiceLogs 2025-12-04 14:24:10 +10:00
Pasha Sviderski 1e6aaf451e feat: support filtering service logs by machine (-m/--machine) 2025-12-03 15:01:23 +10:00
Pasha Sviderski bf65fbaca5 feat --utc flag for 'uc logs' command to print timestamps in UTC 2025-12-03 12:52:20 +10:00
79dc05cb66 feat: service logs command to stream service logs (#196)
* chore: fix landing logo shadow

* feat: implement service logs command with colored output and strict ordering

* revert Makefile

* fix after rebase

* move logs command under services with root shortcut

* simplify server options for streaming logs and CLI flags

* refactor ContainerLogs grpc server

* update --tail flag

* minor docker.proto

* refactor client ServiceLogs and ContainerLogs

* move ProxyMachinesContext from api to client pkg

* minor refactor proto Stream

* add api/logs

* implement LogMerger

* fix LogMerger to correctly use semaphore

* increate inflish entries to 100 per stream

* send heartbeats

* refactor ContainerLogs to synchronise Send of entries and heartbeats to the steram

* minor logmerege

* remove ContainerName form ServiceLogEntryMetadata

* refactor ContainerLogs into docker.Service

* detect stalled container logs streams

* update logmerger tests

* fix comment in test

* refactor LogMerger with options

* uc logs: format one or multiple services

* make LogMerger emit heartbeats, emit entries <= watermark, rewrite tests

* update uc logs with new LogMerger

* go mod tidy

* fix after merge

---------

Co-authored-by: Evgenii Orlov <evgenii.orlov@semrush.com>
2025-12-02 19:50:48 +10:00
Anton OvchinnikovandGitHub 234985b57d feat(install): Add install-only mode to the install script (#194) 2025-11-30 20:00:33 +01:00
Justin BradfordandGitHub d89bfb8e03 feat: add uc ps command to list all containers in the cluster (#165)
* feat: Add `uc ps` command to list all containers in the cluster

* Fix indentation

* Improvements to the container state highlights code (use enum, better var naming)

* Add group/sort option by "health"

* Rename `uc ps --group-by` flag to `--sort`. Also, remove `--context` flag.

* Use huh.spinner with standard style/type

* Remove pointless TrimPrefix on container name

* Move machine lookup out of services loop

* Use more efficient `ListServiceContainers`

* Use consistent header other

* Use api.ProxyMachinesContext helper for context to query all machines for service containers

* Make errored MachineServiceContainer entries a warning, not return an error immediately, in `uc ps`

* Use lipgloss table instead of tabwriter for "uc ps" command

* Handle nil metadata in `uc ps` on container when cluster only has one machine
2025-11-27 20:05:58 +10:00
91a09d5d37 feat: add CLI connector for unix domain socket (#186)
* feat: Add cli connector for unix domain socket

* Update cmd/uncloud/main.go

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
2025-11-25 21:45:07 +10:00
Pasha Sviderski e87207eae3 chore: fix landing logo shadow 2025-11-24 09:40:36 +10:00
Anton Ovchinnikov 3c33973269 chore(exec): Update TODO regarding signal forwarding 2025-11-23 23:58:53 +01:00
Pasha Sviderski 3019377310 chore: update logo for docs 2025-11-21 22:41:17 +10:00
Pasha Sviderski 53def3a624 chore: update white color in logo-title-dark.svg 2025-11-21 22:38:13 +10:00
Pasha Sviderski eb23e956cb docs: README subtitle minor 2025-11-21 22:33:09 +10:00
Pasha Sviderski d029d4d0bf docs: update uncloud logo with better spacing 2025-11-21 22:26:29 +10:00
Pasha Sviderski 4433095045 chore: show links to docs and Discord for the root 'uc --help' command 2025-11-21 22:08:32 +10:00
Pasha Sviderski 05c8117f7b chore: add redirect from uncloud.run/discord to discord invite 2025-11-21 21:03:48 +10:00
Pasha Sviderski 3fbba6d540 chore: sort services in 'uc ls' output by name 2025-11-21 18:11:20 +10:00
Pasha Sviderski 439d956ac2 docs: Deploy an app: build secrets are not supported (resolves #181) 2025-11-20 17:32:37 +10:00
Pasha Sviderski 055cfae272 docs: Compose compatibility matrix: deploy.rollback_config and deploy.update_config are not supported (resolves #179) 2025-11-20 17:15:40 +10:00
Justin BradfordandGitHub 76b4369aa6 feat: store machine id on connection entries in Uncloud config so it can be removed with machine (#182)
* fix: Store machine id on connection entries so it can be removed when machine is

* Add "GetContextOverrideOrCurrent" helper method to cli and use in `uc machine rm` to clean up connections
2025-11-20 16:49:37 +10:00
Pasha Sviderski 74c7738bc3 docs: update 'uc service rm' description that volumes are preserved 2025-11-20 16:36:36 +10:00
Pasha Sviderski f6d75d507c lint 2025-11-20 16:04:06 +10:00
Pasha Sviderski 6a1cd7bf87 refactor: replace loadProjectFromContent with compose.LoadProjectFromContent 2025-11-20 16:03:47 +10:00
Pasha Sviderski 57205e1149 fix: new Compose deployment with volumes and --recreate flag (fixes #176) 2025-11-20 15:19:44 +10:00
Pasha Sviderski ce4a8cce72 docs: regenereate CLI reference 2025-11-20 10:53:37 +10:00
Justin BradfordandGitHub 75085b3929 refactor: Simplify dns e2e test using new ExecContainer functionality (#178)
* refactor: Simplify dns e2e test using new ExecContainer functionality

* Fix linter errors
2025-11-20 08:43:58 +10:00
Pasha Sviderski c489fcc63c docs: typo 2025-11-20 08:06:26 +10:00
Pasha Sviderski e30ea89990 chore: enable CGO to be able to build fsevent required by updated compose on macOS 2025-11-20 08:06:26 +10:00
Pasha Sviderski 9ddcbd6de0 docs: render CLI reference 2025-11-20 08:06:26 +10:00
Justin BradfordandGitHub 939645e144 refactor: Make --context cli flag a global option (#174)
* Make `--context` flag a global option and remove duplication

* Remove unused dns showOptions struct

* First pass of refactoring "active context" determination and validation

* Undo some refactoring in cli.go to keep diff more focused
2025-11-19 20:30:32 +10:00
Justin BradfordandGitHub 1d413734d8 feat: Interactive 'uc ctx connection` command to select preferred cluster connection (#170)
* feat: Interactive uc command to select preferred cluster connection

https://github.com/psviderski/uncloud/issues/125

* Simplify connection selection and reordering by using the index instead of a pointer

* Move connection reordering logic to a new SetDefaultConnection on context
2025-11-19 13:53:29 +10:00
JiraliteandGitHub 2e655d870c chore: extract version from latest redirect in install_cli.sh (#180) 2025-11-18 13:31:35 +10:00
Luis LavenaandGitHub eea81723b8 feat(ssh+cli): Support SSH CLI on machine init and add commands (#173)
* Add SSHCLIRemote type with buildSSHArgs

Implement's Executor interface (Run, Stream, Close)

* Add UseSSHCLI field to RemoteMachine struct

Allow us to differentiate regular ssh from ssh+cli.

* Parse ssh+cli:// scheme on machine init/add

* Support SSH CLI when provisioning

* Update docs to reflect ssh+cli:// support

* Revert removal of `init` examples

Fixes copy & pasta mistake when documenting the new ssh+cli connection
methods.
2025-11-17 20:11:31 +10:00
Pasha Sviderski bbdeedb44e docs: add Image tag format reference doc 2025-11-12 23:12:59 +10:00
Pasha Sviderski 3e64af910a docs: split 'Deploy to specific machines' and 'Deploy a global service' into separate guides 2025-11-12 18:35:00 +10:00
Pasha Sviderski fcdbf785bb docs: init Guides section 2025-11-12 15:54:29 +10:00
Pasha Sviderski 7ebcba7c36 docs: add 'Deploy an app' how-to guide, init 'Image tag format' reference 2025-11-12 15:54:05 +10:00
Pasha Sviderski 90d244fea8 chore: 'uc image push' fix long description 2025-11-12 09:28:54 +10:00
Pasha Sviderski 497b9483f6 chore: fix the examples for 'uc image push' command after changing default 2025-11-12 09:25:19 +10:00
Pasha Sviderski e33fd40033 docs: split Compose docs into reference and concepts 2025-11-11 23:06:21 +10:00
Pasha Sviderski 2b93a1c089 chore: update --container flag help that accepts ID prefix 2025-11-10 08:41:25 +10:00
Anton OvchinnikovandGitHub 15d4ef5518 feat: Allow container matching by ID prefix (#175) 2025-11-10 08:38:20 +10:00
Anton Ovchinnikov 2f5f515696 build: Bump go tests timeout to 15 min 2025-11-09 18:26:28 +01:00
Anton Ovchinnikov 30bb33f1a0 fix(exec): Handle context cancellation server-side 2025-11-09 18:19:41 +01:00
Anton Ovchinnikov 7bff706aa0 ref: Rewrite config tests with Exec 2025-11-09 17:37:07 +01:00
Pasha SviderskiandGitHub b0cd752619 docs: add line break before uncloud-recipes block 2025-11-07 21:53:46 +10:00
Pasha Sviderski 52dd0c71a5 docs: link uncloud-recipes repo in README 2025-11-07 21:47:41 +10:00
Pasha Sviderski 2d292ed053 chore: remove x-machines from website compose 2025-11-07 18:31:05 +10:00
Pasha Sviderski 9770151583 fix: 'uc deploy' push image to all machines if x-machines is not specified 2025-11-07 15:38:52 +10:00
Pasha Sviderski 89db50e8df chore: consistent casing 2025-11-07 14:29:27 +10:00
Pasha Sviderski 3ac5992e1d chore: use short container ID for stop operation in deploy plan 2025-11-07 13:53:30 +10:00
Pasha Sviderski 8c4f21ff81 docs: regenerate CLI reference docs 2025-11-07 13:29:30 +10:00
Pasha Sviderski 4de068c46e chore: pin corrosion version to the latest working version v0.2.2 2025-11-07 12:51:40 +10:00
Pasha Sviderski b6dfc2175f fix: reconfigure WireGuard peers when listed >=1 machines in cluster store (fixes #155) 2025-11-07 12:51:40 +10:00
Luis LavenaandGitHub 48d2239f5d Connect to remote SSH nodes using SSH CLI (#152)
* Connect to remote SSH nodes using SSH CLI

Replace Go-native SSH implementation with SSH CLI execution
to support diverse SSH configurations and agents. Implements
'uncloudd dial-stdio' subcommand that proxies gRPC connections
over stdin/stdout, similar to Docker's approach.

This change addresses compatibility issues with:
- SSH agents exposing many keys (1Password, causing "too many
  authentication failures")
- Tailscale SSH (which doesn't support advanced SSH channel
  types like direct-streamlocal)
- Custom SSH configurations in ~/.ssh/config

The dial-stdio approach reduces SSH feature requirements by
streaming the unix socket connection over stdin/stdout instead
of using SSH channel forwarding.

Changes:
- Add 'uncloudd dial-stdio' hidden subcommand for socket proxy
- Add SSHCLIConnector using ssh command + dial-stdio
- Update connection logic to use SSH CLI connector
- Maintain backward compatibility with SSHKeyFile config

Resolves #131

* Fix sshcli tests missing ConnectionTimeout

Introduced short connection timeout on the first change but forgot to
update tests to match.

* Add SSHCLI field and update MachineConnection String() format

- Add SSHCLI field to support ssh_cli YAML configuration
- Update String() to use URI-like format (ssh://, ssh+cli://, tcp://)

* Add Validate() method and tests for MachineConnection

- Add Validate() to ensure connection methods are mutually exclusive
- Add tests for validation and String() method

* Unify SSH connector configs to use SSHConnectorConfig

* Update connectCluster to support both SSH connector types

* Restore Go SSH connector as default for machine init/add

Revert provisionOrConnectRemoteMachine to use Go SSH connector:
- Root users: reuse SSH connection from provisioning
- Non-root users: establish new connection for group membership
- Remove SSH CLI as default connector

SSH CLI connector remains available via ssh_cli config field.

* Add sshCLIDialer with DialContext method

Implement proxy.ContextDialer for SSHCLIConnector using SSH -W flag.
Each dial spawns a new SSH process for TCP forwarding, enabling
independent connections separate from the gRPC dial-stdio connection.

* Implement SSHCLIConnector.Dialer() method

Return sshCLIDialer instead of error, enabling uc image push
functionality with SSHCLIConnector. Validates connector is
configured before returning dialer.

* Fix half-closing implementation matchin Docker's approach

* Use testify assertions for connection tests

* Allow ssh+cli:// to be used with --connect

This way I can skip the configuration file while testing things out,
and confirm it works correctly:

  $ unset SSH_AUTH_SOCK

  $ ./uncloud --connect ssh://provision@blatta11 machine ls
  Error: connect to cluster: connect to machine: SSH login to
    provision@blatta11:22: connect using SSH agent: connect to SSH
    agent: dial unix: missing address

  $ ./uncloud --connect ssh+cli://provision@blatta11 machine ls
  NAME       STATE   ADDRESS         PUBLIC IP   WIREGUARD ENDPOINTS                                                                                                                  MACHINE ID
  blatta11   Up      10.210.0.1/24   -           100.64.0.22:51820, ...

* Validates configuration before connecting to cluster

* Do not tie client constructor with real validation

No longer attempt to validate the connection when instantiating a new
client.

Later on we could validate it in different places.

* Cleanup test and remove AI-slop

There were some serious slop in those tests, so took the time to clean
them up and kept only the relevant ones.

There is some repetition between buildSSHArgs and buildDialArgs but can
be tackled at a later stage.

* Fix connection representation tests

Prefix connection with ssh ssh+cli respectively.

* Wait for stdout before returning

Missed copy & pasta from Docker dial-stdio implementation (this happens
when you stare at the code for too long that it burns your eyes).
2025-11-06 09:44:29 +10:00
Anton Ovchinnikov 06d988583d doc: Generate docs 2025-11-05 23:48:30 +01:00
Anton OvchinnikovandGitHub abea3e229e feat: "exec" command to start processes inside remote containers (#139) 2025-11-05 23:47:24 +01:00
Justin BradfordandGitHub 1c3f210848 bug: Use GITHUB_TOKEN if present in scripts/install_cli.sh (#169)
https://github.com/psviderski/uncloud/issues/163
2025-11-05 15:52:28 +10:00
Pasha Sviderski 722615cd9b chore: go mod tidy 2025-11-04 18:22:29 +10:00
Pasha Sviderski 809d1eca77 lint 2025-11-04 18:20:10 +10:00
Pasha Sviderski 6e4439bf49 chore: show container image for the remove container plan operation 2025-11-04 18:15:16 +10:00
Pasha Sviderski 882f2f5d03 feat(build,deploy): build service images using compose (bake/buildkit), push to cluster, and deploy 2025-11-04 18:14:42 +10:00
Pasha Sviderski dde23b5549 chore: increase timeout 5->30s for waiting for machines to be ready in ucind cluster 2025-11-04 12:40:15 +10:00
Pasha Sviderski c3ecd378f8 feat(build): --push and --push-registry flags to push build images to cluster or registries 2025-11-03 15:38:59 +10:00
Pasha Sviderski 6da8e98c62 chore: add TODO to refactor docker client method 2025-11-03 13:45:45 +10:00
Pasha Sviderski 4d591f9efc lint 2025-11-01 16:07:36 +10:00
Pasha Sviderski 26c3699a9e feat: process image templates in compose services, set default git-based tags if not specified 2025-11-01 16:06:52 +10:00
Pasha Sviderski 328ace1fd1 chore: implement image template processing using git repo state (not enabled) 2025-11-01 15:50:36 +10:00
Pasha Sviderski 91bf135a4f docs: minor README update 2025-11-01 14:01:23 +10:00
Pasha Sviderski e2b3260926 docs: update README unregistry is already implemented, not coming soon 2025-11-01 14:00:38 +10:00
Pasha Sviderski 179289b504 test: InspectGitState when git not available 2025-10-31 17:34:55 +10:00
Pasha Sviderski bd8a843972 chore: inspecrt git state without an error if git utility not available 2025-10-31 17:17:29 +10:00
Pasha Sviderski 7e366ff980 chore: add gitutils package to inspect local git repo 2025-10-31 15:57:43 +10:00
Pasha Sviderski d0c23fa840 lint: fix ineffassign 2025-10-30 21:01:18 +10:00
Pasha Sviderski 4bc47af05b refactor: strip project name from volume names without mangling project name 2025-10-30 20:54:15 +10:00
Pasha Sviderski 65c3e0a68d feat(build): print if no services to build, build dependencies --dep, check build configuration --check 2025-10-30 20:54:15 +10:00
Pasha Sviderski 5c4f49b685 feat: new cbuild (compose build) command using Compose implementation 2025-10-30 20:54:15 +10:00
Justin BradfordandGitHub ba6290d616 feat: add "nearest.<service>.internal" mode to internal DNS (#159)
* feat: Adding a "nearest.service.local" mode to internal DNS

* Pass listenAddr to internal DNS server (rather than calling into network.MachineIP)

Also adds an exact round-robin via "rr.{service}" mode. Not sure we want that over random or not, though.

* Remove the resolved count and rotating round-robin. "rr.name" mode is now the same as default with random shuffle.

* Add test on nearest internal DNS lookup

* Add docs on internal DNS order modes
2025-10-29 14:25:59 +10:00
Anton OvchinnikovandGitHub 6af7a9861d feat: Allow to match containers against truncated ID (#160) 2025-10-28 18:44:04 +01:00
Justin BradfordandGitHub 3b4bcb7932 feat: support service.gpus and reservations.devices (#156)
* feat: support compose.yaml `service.gpus` and `service.deploy.resources.reservations.devices` DeviceRequests to Docker container
* Fix lint error
* Adjust test comments to work with linter
* Rename DeviceRequests to DeviceReservations to be more consistent with compose nomenclature
* Device reservation changes are immutable and should trigger a container recreate rather than update
2025-10-27 16:49:38 +10:00
Luis LavenaandGitHub 0153e9f9e2 fix: progress percent overflow in image push (#153) 2025-10-22 14:52:57 +10:00
Pasha Sviderski 9a0b1a4ce3 chore: print (custom Caddy config) for service ENDPOINTS if x-caddy is used 2025-10-21 11:42:18 +10:00
Anton Ovchinnikov e251dd950b fix: Improve wording for containerd warning 2025-10-16 23:33:25 +02:00
Pasha Sviderski 40862df038 fix: compose variables are now interpolated from .env file (fixes #144) 2025-10-15 17:45:53 +10:00
Pasha Sviderski dc569175ef docs: add info that caddy could be deployed on specific machines 2025-10-13 20:40:11 +10:00
Pasha Sviderski 6ba98e4576 fix: 'uc image push' closed network connection error if takes >10s (fixes #141) 2025-10-13 20:19:46 +10:00
Pasha Sviderski 75b932b66e docs: regenerate CLI reference docs 2025-10-10 18:57:15 +10:00
Pasha Sviderski 5aa85ab501 fix(push): always provide encoded empty auth config to work around panic in Docker 2025-10-09 15:14:10 +10:00
Pasha Sviderski 521ccf2026 chore(push): run unregistry only if containerd image store enabled for docker 2025-10-09 12:48:51 +10:00
Pasha Sviderski e8b4dd0bd1 chore(push): fail 'image push' when containerd image store not used on target machine 2025-10-09 12:11:18 +10:00
Pasha Sviderski 265042e2c8 lint 2025-10-08 16:38:40 +10:00
Justin BradfordandGitHub 1c85112d4f docs: Rough notes for "services" docs with examples of internal DNS lookups and container ENV (#136) 2025-10-08 16:35:52 +10:00
Pasha Sviderski 0fc5032b27 feat(images): add IN USE column to highlight which images are used by any container 2025-10-08 15:43:54 +10:00
Pasha Sviderski 893ab1f9f5 fix: negotiate docker client API version in e2e test 2025-10-08 14:18:11 +10:00
Pasha Sviderski a30ed600b6 chore: update docker and compose Go dependencies to the latest versions 2025-10-08 13:56:58 +10:00
Pasha Sviderski a2c6cbe633 chore(images): filter images by name with wildcard pattern using Docker filter 2025-10-08 12:33:13 +10:00
Pasha Sviderski 1f9c2c873d feat(images): list images filtered by name 2025-10-07 20:39:31 +10:00
Pasha Sviderski 509c9eb7d3 chore(images): format platforms as pills, sort images by name 2025-10-07 19:14:40 +10:00
Pasha Sviderski fba03b3df7 feat(images): list image platforms and do not group images by machine 2025-10-07 17:06:52 +10:00
Pasha Sviderski c6d18f5d49 chore: NAME -> IMAGE column name 2025-10-07 15:12:05 +10:00
3e804d9ecc feat: set UNCLOUD_MACHINE_ID in service container ENV (#135)
* Set machine id in service container ENV

* Add machine id to clone of the container's env map to avoid exposing serialization format

* fix: panic on setting UNCLOUD_MACHINE_ID env var when Env is nil in the spec

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
2025-10-07 14:12:00 +10:00
Pasha Sviderski 784d86d8e8 fix: test build for docker server 2025-10-03 21:10:02 +10:00
Pasha Sviderski 3bac1e61b9 chore: go mod tidy 2025-10-03 20:54:59 +10:00
Pasha Sviderski 6d2e100d63 feat(images): 'uc image ls' and 'uc images' (alias) commands to list images on machines 2025-10-03 20:53:30 +10:00
Pasha Sviderski cce81ecaad chore(images): simplifies ListImages service, server, and client to only list Docker images and not directly access containerd 2025-10-03 18:25:39 +10:00
Pasha Sviderski f8bf6bccd3 test: basic e2e test for ListImages 2025-10-03 16:55:20 +10:00
Pasha Sviderski 82fd9744bf chore: remove unused imports 2025-10-03 15:03:19 +10:00
Pasha Sviderski cdff036935 feat(images): gRPC client and server for ListImages 2025-10-03 14:23:18 +10:00
Pasha Sviderski 458d282357 chore: correct comment and add TODO to move machine resolution to grpc-proxy router 2025-10-02 20:12:45 +10:00
Pasha Sviderski 59074a275f chore(images): initialise Docker service with containerd client 2025-10-02 15:51:54 +10:00
Pasha Sviderski 92975eec51 feat(images): add gRPC proto ListImages 2025-10-02 12:50:53 +10:00
Pasha Sviderski e0d11a8abc chore: configure Docker to use containerd image store by default (closes #129) 2025-10-01 15:00:19 +10:00
Pasha Sviderski fa004d63bb chore: add firewall rule to allow cluster machines to push to other machines unregistry 2025-10-01 12:34:03 +10:00
Pasha Sviderski a217643ac9 chore: shorten socat proxy container progress message 2025-09-30 19:44:54 +10:00
Pasha Sviderski fc3297c7b7 chore: go mod tidy 2025-09-30 19:31:14 +10:00
Pasha Sviderski 684f0d38ee feat(push): '--platform all' to push image to all machines in cluster 2025-09-30 19:23:01 +10:00
Pasha Sviderski 332e7bca90 feat(push): --platform flag to push a specific platform of a multi-platform image 2025-09-30 18:59:03 +10:00
Pasha Sviderski ae458d260f chore: refactor duplication 2025-09-30 15:42:01 +10:00
Pasha Sviderski 6b758c810e docs: image push example 2025-09-30 15:24:40 +10:00
Pasha Sviderski ffed906cef feat(push): 'image push' to single or multiple machines via unregistry proxy 2025-09-30 15:18:26 +10:00
Pasha Sviderski bcc504b8cc chore: internal Docker client PushImage 2025-09-30 13:58:34 +10:00
Anton Ovchinnikov 782f4cb20b chore: Update AI.md with instructions around tests and docs 2025-09-29 00:02:58 +02:00
Pasha Sviderski aebd2ac232 docs: review and update Compose support matrix 2025-09-26 22:42:07 +10:00
Pasha Sviderski 2969b40ad4 chore: return a proxy dialer for SSH connections 2025-09-26 21:24:56 +10:00
Pasha Sviderski e0045c7570 chore: refactor gRPC docker client 2025-09-26 21:24:56 +10:00
Pasha Sviderski bc6f2fd49d chore: fix docker client CreateContainerWithImagePull, add WaitPortPublished 2025-09-26 21:24:56 +10:00
Pasha Sviderski cd25b973ea fix: docker client PullImage 2025-09-26 21:24:56 +10:00
Pasha Sviderski 27bbbe7186 chore: internal docker package with handy PullImage and CreateContainerWithImagePull methods 2025-09-26 21:24:56 +10:00
Pasha Sviderski 20cf98d42e fix: return an error if 'uc rm' tries to remove a service that doesn't exist 2025-09-26 21:24:56 +10:00
Anton OvchinnikovandGitHub 63c4de512e feat: Initial support for Compose configs (#116) 2025-09-26 21:24:10 +10:00
Anton Ovchinnikov 337c15de35 chore: Add node to Mise 2025-09-24 23:16:36 +02:00
Justin BradfordandGitHub 2d361d3ee6 Adding machine.service.internal dns entries (#123) 2025-09-24 20:06:54 +10:00
Pasha Sviderski 977fdf3cc3 chore: add Proxy for proxing local connections to a remote TCP address using the dialer 2025-09-24 19:52:37 +10:00
Pasha Sviderski 9f6880b701 feat(push): stub for 'image push' command and Dialer interface for cluster connectors 2025-09-23 16:54:47 +10:00
Pasha Sviderski 1a1afece7d fix: e2e test rebind the registry port to unoccupied 5001 2025-09-23 15:26:36 +10:00
Pasha Sviderski 08f233c5b1 fix: auto-detect containerd.sock path and conditionally start unregistry 2025-09-23 15:10:34 +10:00
Pasha Sviderski 9438654d99 docs: fix comment in schema.sql 2025-09-23 12:21:45 +10:00
Pasha Sviderski 60926f64cc feat: show IMAGE column when listing services (uc ls) #13 2025-09-22 14:03:45 +10:00
Pasha Sviderski b6b8286118 feat: run embedded unregistry on machine IP in uncloudd daemon 2025-09-19 20:43:20 +10:00
Pasha Sviderski 8f906cfd95 fix: do not auto-confirm deploy plan on CI (no TTY), require explicit --yes or UNCLOUD_AUTO_CONFIRM=true 2025-09-18 12:12:06 +10:00
Pasha Sviderski da03ed8ece docs: generate CLI reference docs 2025-09-17 15:11:36 +10:00
Pasha Sviderski 7239cd3a6b docs: clarify 'machine init' help, --context flag description, add examples 2025-09-17 15:10:59 +10:00
Pasha Sviderski 3db07ca2d4 feat: allow setting config and connection with UNCLOUD_CONFIG and UNCLOUD_CONNECT env vars 2025-09-16 10:17:44 +10:00
Pasha Sviderski f8fe3c7af3 feat: --no-install flag for 'machine init/add' to skip installing Uncloud daemon and dependencies #122 2025-09-15 20:44:35 +10:00
Pasha Sviderski b6e9766f40 lint: gRPC status.Error for static error messages 2025-09-15 17:11:12 +10:00
Pasha Sviderski 1f62e30a08 chore: update mise checksums for linux-x64 2025-09-13 22:02:11 +10:00
Pasha Sviderski 88f05ffd75 chore: update mise checksums for macos-arm64 2025-09-13 21:50:40 +10:00
Pasha Sviderski 375bb385a2 chore: bump golangci-lint to 2.4.0 on CI 2025-09-13 21:44:45 +10:00
Pasha Sviderski 51840e0366 chore: bump golangci-lint to 2.4.0 2025-09-13 21:20:48 +10:00
Pasha Sviderski 831c581e0f feat: try each cluster connection in order until one succeeds, add progress spinner 2025-09-13 21:12:20 +10:00
Pasha Sviderski e1734a1aa6 chore: bump Go to 1.25.1 2025-09-13 21:06:17 +10:00
Pasha Sviderski 84376bb34c chore: mise.lock include checksums for linux-x64 2025-09-12 23:18:45 +10:00
Pasha Sviderski 6d0b1dc1ee chore bump mise version on CI to 2025.9.6 2025-09-12 22:58:26 +10:00
Pasha Sviderski bc078472d4 feat: add -y|--yes flag for 'us deploy' to auto-confirm deployment plan 2025-09-12 22:52:06 +10:00
Pasha Sviderski 8a273dbf63 chore: rename cluster -> context option in commands for consistency 2025-09-11 20:21:07 +10:00
Pasha Sviderski 001acfc6a5 docs: remove --watch flag from compose file for deploying caddy 2025-09-10 17:32:15 +10:00
Pasha Sviderski 7b88b8b810 fix: write generated Caddyfile to disk only if succesfully loaded into local Caddy (means valid) 2025-09-10 16:59:31 +10:00
Pasha Sviderski 97bdb8eae1 fix: skip validation for user-defined Caddy configs if caddy not running locally 2025-09-10 15:04:45 +10:00
Pasha Sviderski 5baa8087e5 chore: load Caddy config via admin API instead of watching Caddyfile change on fs 2025-09-09 19:10:32 +10:00
Pasha Sviderski d25864e52f feat: automatically generate a unique 'default-N' context name for new cluster (fixes #113) 2025-09-08 20:35:17 +10:00
Anton Ovchinnikov ec2787c99c fix: Handle implicit relative path for config
Fixes #117
2025-09-07 15:58:09 +02:00
Anton Ovchinnikov 9963f9df2d chore: Update bug template 2025-09-07 15:13:15 +02:00
Anton Ovchinnikov 835834322b chore: Add GH bug report template 2025-09-07 15:02:59 +02:00
Pasha Sviderski 51ba3c7df8 fix: landing navbar on mobile 2025-09-04 18:15:52 +10:00
Pasha Sviderski 047f661462 docs: finish the Deploy demo app page 2025-09-03 20:42:26 +10:00
287 changed files with 23563 additions and 3714 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: Bug Report
about: Create a report to help us improve
title: "[BUG] "
labels: bug
assignees: ""
---
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
**How to reproduce**
<!-- Steps to reproduce the behavior:
1. Run ...
2. Do ...
-->
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Environment:**
- Uncloud versions:
- Control (client) node (`uc --version`):
- Uncloud daemon (from the server) (`uncloudd --version`):
- OS version (`uname -a`):
- Client (control node):
- Server:
**Additional context**
<!-- Add any other context about the problem here. -->
+25 -12
View File
@@ -18,6 +18,8 @@ on:
- "test/**"
permissions:
contents: read
env:
MISE_VERSION: "2026.3.7"
jobs:
test:
runs-on: ubuntu-latest
@@ -25,10 +27,12 @@ jobs:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- name: Install Mise
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with:
go-version: "1.23.2"
version: ${{ env.MISE_VERSION }}
- name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock
- name: Install dependencies
run: |
@@ -36,11 +40,17 @@ jobs:
git diff --exit-code ||
(echo "go.mod or go.sum has changed. Please run 'go mod tidy' and commit the changes." && exit 1)
- name: Generate docs
run: |
make cli-docs
git diff --exit-code ||
(echo "Documentation is out of date. Please run 'make cli-docs' and commit the changes." && exit 1)
- name: Run tests
run: |
make ucind-image
mise ucind:image
make test
timeout-minutes: 10
timeout-minutes: 15
check-protobuf:
runs-on: ${{ matrix.os }}
@@ -55,15 +65,18 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Mise
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with:
version: "2025.6.5"
env:
GITHUB_TOKEN: ${{ github.token }}
version: ${{ env.MISE_VERSION }}
# TODO: figure out how to properly lock all platforms in the lockfile and remove this hack.
# `mise lock` (mise run mise:lock) doesn't generate checksums for some of the tools, e.g. protoc*
- name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock
- name: Generate Protobuf files
run: |
make proto
# check if the generated files are up to date
mise run proto
# Check if the generated files are up to date.
git diff --exit-code ||
(echo "Generated protobuf files are not up to date. Please run 'make proto' and commit the changes." && exit 1)
(echo "Generated protobuf files are not up to date. Please run 'mise run proto' and commit the changes." && exit 1)
+9 -13
View File
@@ -13,6 +13,8 @@ on:
- "go.*"
permissions:
contents: read
env:
MISE_VERSION: "2026.3.7"
jobs:
lint:
runs-on: ubuntu-latest
@@ -20,20 +22,14 @@ jobs:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- name: Install Mise
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with:
go-version: "1.23.2"
version: ${{ env.MISE_VERSION }}
- name: Reset changes in mise.lock after fresh mise install
run: git checkout mise.lock
- name: golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
with:
version: v2.2.2
- name: Format code
run: |
make format
git diff --exit-code ||
(echo "Code is not formatted. Please run 'make format' and commit the changes." && exit 1)
- name: Lint
run: make lint
timeout-minutes: 10
+7
View File
@@ -7,6 +7,10 @@
*.dll
*.so
*.dylib
/uncloud
/uncloud-*
/uncloudd
/uncloudd-*
# OS X
.DS_Store
@@ -37,3 +41,6 @@ node_modules/
# VS Code
.vscode
.devcontainer/
.zed/
+2 -1
View File
@@ -16,7 +16,8 @@ builds:
main: ./cmd/uncloud
binary: uncloud
env:
- CGO_ENABLED=0
# Use CGO to be able to build fsevent required by compose on macOS.
- CGO_ENABLED={{ if eq .Os "darwin" }}1{{ else }}0{{ end }}
goos:
- darwin
- linux
-47
View File
@@ -1,47 +0,0 @@
[tools."aqua:vektra/mockery"]
version = "3.5.3"
backend = "aqua:vektra/mockery"
[tools."aqua:vektra/mockery".checksums]
"mockery_3.5.3_Darwin_arm64.tar.gz" = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
"mockery_3.5.3_Linux_x86_64.tar.gz" = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
[tools.go]
version = "1.23.10"
backend = "core:go"
[tools.go.checksums]
"go1.23.10.darwin-arm64.tar.gz" = "sha256:25c64bfa8a8fd8e7f62fb54afa4354af8409a4bb2358c2699a1003b733e6fce5"
"go1.23.10.linux-amd64.tar.gz" = "sha256:535f9f81802499f2a7dbfa70abb8fda3793725fcc29460f719815f6e10b5fd60"
[tools.golangci-lint]
version = "2.2.2"
backend = "aqua:golangci/golangci-lint"
[tools.golangci-lint.checksums]
"golangci-lint-2.2.2-darwin-arm64.tar.gz" = "sha256:d84d94d042c0d495fd1746f3d18948a75de163b17a14e8de3ef840928dd2df74"
"golangci-lint-2.2.2-linux-amd64.tar.gz" = "sha256:c27fbde948a87d326feacd21df2f61a9c54dbd2e3bfa185c0a1cd6917a6f964f"
[tools.protoc]
version = "27.3"
backend = "aqua:protocolbuffers/protobuf/protoc"
[tools.protoc.checksums]
"protoc-27.3-linux-x86_64.zip" = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
"protoc-27.3-osx-aarch_64.zip" = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
[tools.protoc-gen-go]
version = "1.34.2"
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
[tools.protoc-gen-go.checksums]
"protoc-gen-go.v1.34.2.darwin.arm64.tar.gz" = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
"protoc-gen-go.v1.34.2.linux.amd64.tar.gz" = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
[tools.protoc-gen-go-grpc]
version = "1.5.1"
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
[tools.protoc-gen-go-grpc.checksums]
"protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz" = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
"protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz" = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
-13
View File
@@ -1,13 +0,0 @@
[settings]
experimental = true
[tools]
"aqua:vektra/mockery" = "3.5.3"
go = "1.23"
golangci-lint = "2.2.2"
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"
+30 -9
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
**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
@@ -28,7 +32,7 @@ This document provides comprehensive information about the Uncloud project for A
- **Docker Compose compatibility**: Uses familiar Docker Compose format
- **Zero-downtime deployments**: Rolling updates without service interruption
- **Automatic HTTPS**: Caddy reverse proxy with Let's Encrypt integration
- **Managed DNS**: Free `*.cluster.uncloud.run` subdomains via Uncloud DNS service
- **Managed DNS**: Free `*.xxxxxx.uncld.dev` subdomains via Uncloud DNS service
- **Cross-machine scaling**: Run containers across multiple machines
### 🔧 Developer Experience
@@ -88,6 +92,10 @@ This document provides comprehensive information about the Uncloud project for A
- **`scripts/`**: Installation and utility scripts
- **`test/`**: Test suites and test infrastructure
- **`website/`**: Documentation website (Docusaurus)
- `landing/`: Landing page
- `docs/`: User documentation
- **`misc/`**: Design documents and guides
## Key Technologies
@@ -184,9 +192,11 @@ uc context use <name> # Switch context
### Testing
- Unit tests alongside source files (`*_test.go`)
- Integration tests in `test/e2e/`
- Test fixtures in `test/fixtures/`
- Test files and locations
- Unit tests alongside source files (`*_test.go`)
- Integration tests in `test/e2e/`
- Test fixtures in `test/fixtures/`
- Use table driven tests whenever possible
### Dependencies
@@ -233,7 +243,7 @@ uc context use <name> # Switch context
- `internal/machine/machine.go`: Core machine management
- `pkg/api/`: Public API definitions
- `misc/design.md`: Architecture and design philosophy
- `README.md`: User-facing documentation
- `README.md`: Repository README
### Configuration Files
@@ -241,4 +251,15 @@ uc context use <name> # Switch context
- `Makefile`: Build and development tasks
- `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
Instructions when generating documentation:
- Use conversational language write as if you were speaking to a friend.
- Keep sentences simple, optimize for clarity and understanding.
- 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
+6 -5
View File
@@ -1,6 +1,6 @@
ARG ALPINE_VERSION=3.20.3
ARG ALPINE_VERSION=3.23.3
FROM golang:1.23.2-alpine AS uncloudd
FROM golang:1.26.1-alpine AS uncloudd
ARG TARGETOS
ARG TARGETARCH
@@ -13,7 +13,7 @@ COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd cmd/uncloudd/main.go
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd ./cmd/uncloudd
FROM alpine:${ALPINE_VERSION} AS corrosion-download
@@ -39,10 +39,11 @@ CMD ["corrosion", "agent"]
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
ARG TARGETOS
ARG TARGETARCH
RUN apk --no-cache add crane
RUN crane pull "${CORROSION_IMAGE}" /corrosion.tar
RUN crane pull --platform ${TARGETOS}/${TARGETARCH} "${CORROSION_IMAGE}" /corrosion.tar
# Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker.
FROM docker:27.3.1-dind AS ucind
+154
View File
@@ -0,0 +1,154 @@
# Developing Uncloud
A short guide on setting up your local development environment.
## Prerequisites
- [mise](https://mise.jdx.dev/) (development environment setup tool)
- [Docker](https://docs.docker.com/get-docker/) (for running end-to-end tests)
## Setup
Uncloud uses `mise` to install and manage development tools and dependencies. It makes it easy to create reproducible
development environments on developer machines and CI.
Follow the [Installing Mise](https://mise.jdx.dev/installing-mise.html) guide to get it set up on your machine.
Mark the project as trusted and install all required tools (Go, `protoc`, `golangci-lint`, etc.) by running from the
project root:
```shell
mise trust
mise install
```
This reads [`mise.toml`](mise.toml) and installs the exact versions of tools specified there. They will be available in
your `PATH` when you are in the project directory, so you can run `go` or `golangci-lint` directly. The tools won't be
available outside the project, so they don't interfere with system packages.
## Building
Build the CLI:
```shell
go build -o uc ./cmd/uncloud
```
Or build and run the CLI with a single command:
```shell
go run ./cmd/uncloud --help
```
The Uncloud daemon (`uncloudd`) only supports Linux, so you need to cross-compile it if you're developing on macOS or
Windows:
```shell
GOOS=linux GOARCH=amd64 go build -o uncloudd ./cmd/uncloudd
```
## Testing
Run all tests (unit and e2e):
```shell
make test
```
### End-to-end tests
E2e tests run inside Docker using the [Uncloud-in-Docker](#uncloud-in-docker-ucind) image. Build it first:
```shell
mise ucind:image
```
⚠️ NOTE: You need to rebuild the `ucind` image every time you make changes to the daemon code if you want to test them
in e2e tests.
Then run the tests:
```shell
make test-e2e
```
If tests leave leftover containers, clean them up with:
```shell
make test-clean
```
### Manual testing on dev machines
After making changes to the daemon code, you may want to test them in your dev cluster. Build and install `uncloudd` on
one or multiple dev machines over SSH with:
```shell
mise run dev:install user@host1 user@host2/arm64
```
It will also restart the `uncloud.service` daemon on those machines to pick up the new version. The host format is
`[user@]host[/arch]`. The architecture of a machine defaults to `amd64` if not specified.
To stop the daemon and wipe all Uncloud data on the dev machines, run:
```shell
mise run dev:reset user@host1 user@host2
```
## Linting and formatting
Lint and format the code:
```shell
make lint-and-fix
```
## Code generation
Update the generated Go code for the machine gRPC API after modifying `.proto` files:
```shell
mise run proto
```
Regenerate mocks:
```shell
make mocks
```
## Uncloud in Docker (UCinD)
The [`ucind`](./cmd/ucind) CLI lets you run Uncloud clusters locally using Docker containers instead of real machines.
Each cluster machine runs as a Docker container connected to a shared Docker network.
Cluster machines use the `ghcr.io/psviderski/ucind:latest` Docker image by default. It may not always have the
latest changes to the `uncloudd` daemon code as it's not automatically rebuilt on CI yet.
You can rebuild it locally to pick up the latest changes, including your work in progress, and test them in a UCinD
cluster:
```shell
mise ucind:image
```
Create a cluster with 3 machines:
```shell
mise ucind cluster create -m 3
```
This also sets the new cluster as the active context, so you can interact with it using the `uc` CLI right away.
Remove the cluster when you're done:
```shell
mise ucind cluster rm
```
Sometimes crashed tests or interrupted runs can leave orphaned containers and networks behind. Clean them all up with:
```shell
mise ucind:cleanup
```
+8 -55
View File
@@ -1,54 +1,11 @@
# TODO: Makefile is deprecated, add new targets as mise tasks in mise.toml instead.
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
update-dev:
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
ssh spy@192.168.40.243 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
ssh spy@192.168.40.176 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && \
rm uncloudd-linux-amd64
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
# ssh ubuntu@152.67.101.197 sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && \
# rm uncloudd-linux-arm64
update-restart-dev:
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
ssh spy@192.168.40.243 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
ssh spy@192.168.40.176 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
rm uncloudd-linux-amd64
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
# ssh ubuntu@152.67.101.197 "sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
# rm uncloudd-linux-arm64
reset-dev:
ssh spy@192.168.40.243 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
ssh spy@192.168.40.176 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
ssh ubuntu@152.67.101.197 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
demo-reset:
rm -fv ~/.config/uncloud/config.yaml
ssh ubuntu@152.67.101.197 "AUTO_CONFIRM=true sudo -E uncloud-uninstall && docker rmi caddy:2.9.1"
ssh root@5.223.45.199 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
ssh spy@192.168.40.243 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
.PHONY: ucind-cluster
ucind-cluster:
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 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
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
.PHONY: corrosion-image
corrosion-image:
@@ -58,10 +15,6 @@ corrosion-image:
corrosion-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
.PHONY: ucind-image
ucind-image:
docker build -t "$(UCIND_IMAGE)" --target ucind .
.PHONY: ucind-multiarch-image-push
ucind-multiarch-image-push:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
@@ -73,23 +26,23 @@ mocks:
.PHONY: test
test:
ifeq ($(TEST_NAME),)
go test -count=1 -v ./...
go test -shuffle=on -count=1 -v ./...
else
go test -race -count=1 -v -run ^$(TEST_NAME)$$ ./...
go test -shuffle=on -race -count=1 -v -run ^$(TEST_NAME)$$ ./...
endif
.PHONY: 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
test-clean:
@CONTAINERS=$$(docker ps --filter "name=ucind-test" -q); \
@CONTAINERS=$$(docker ps --filter "label=ucind.managed" -q); \
if [ -n "$$CONTAINERS" ]; then \
echo "Killing containers..."; \
docker kill $$CONTAINERS; \
fi; \
CONTAINERS_STOPPED=$$(docker ps -a --filter "name=ucind-test" -q); \
CONTAINERS_STOPPED=$$(docker ps -a --filter "label=ucind.managed" -q); \
if [ -n "$$CONTAINERS_STOPPED" ]; then \
echo "Removing stopped containers..."; \
docker rm $$CONTAINERS_STOPPED; \
+28 -11
View File
@@ -1,7 +1,7 @@
<div align="center">
<img src="./website/landing/images/logo.svg" height="100" width="100" alt="Uncloud logo"/>
<h1>Uncloud</h1>
<p><strong>Docker simplicity. Multi-machine power.</strong></p>
<img src="./website/landing/images/logo-title.svg#gh-light-mode-only" alt="Uncloud logo"/>
<img src="./website/landing/images/logo-title-dark.svg#gh-dark-mode-only" alt="Uncloud logo"/>
<p><strong>▸ Deploy and scale containerised apps across servers without Swarm or Kubernetes overhead ◂</strong></p>
<p>
<a href="https://uncloud.run/docs"><img src="https://img.shields.io/badge/Docs-blue.svg?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation"></a>
@@ -25,12 +25,14 @@ complexity of Kubernetes.
## ✨ Features
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment,
regardless of location or provider.
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
need to learn a new bespoke DSL.
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
soon.
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Build and push your Docker images directly to
your machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
@@ -38,18 +40,13 @@ complexity of Kubernetes.
* **No control plane**: Fully decentralised design eliminates single points of failure and reduces operational overhead.
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
model and troubleshooting.
* **Managed DNS**: Automatic DNS records `*.<id>.cluster.uncloud.run` for services with public access via managed
* **Managed DNS**: Automatic DNS records `*.xxxxxx.uncld.dev` for services with public access via managed
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
Encrypt.
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
### 🚀 Coming soon
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Push your Docker images directly to your
machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
## 🎬 Quick demo
The screenshot below demonstrates how I use Uncloud to deploy https://uncloud.run website to 2 remote machines from
@@ -64,9 +61,15 @@ Here is a more advanced use case. Deploy a highly available web app with automat
on-premises in just a couple minutes.
<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>
<br>
<br>
> **📚 Want more examples?** Check out the [**uncloud-recipes**](https://github.com/psviderski/uncloud-recipes)
> repository for community recipes and templates for deploying popular services on Uncloud.
## 💫 Why Uncloud?
Modern cloud platforms like Heroku and Render offer amazing developer experiences but at a premium price. Traditional
@@ -278,6 +281,16 @@ using the CLI or directly over SSH. They all have the complete cluster state and
machine is a full backup of your control plane.
</details>
## 🧪 Interactive tutorials
To give you a chance to play with Uncloud without even leaving your browser, we're providing interactive tutorials and playgrounds on the [iximiuz Labs](https://labs.iximiuz.com/) platform.
Available tutorials:
1. [Setting up a new Uncloud cluster](https://labs.iximiuz.com/tutorials/uncloud-create-cluster-ebebf72b) - the tutorial walks you through creating a new cluster with two machines and then deploying a simple web service to it.
You can also launch the [Uncloud playground](https://labs.iximiuz.com/playgrounds/uncloud-cluster-64523f7c) where you can play with an already initialized Uncloud cluster.
## 🏗 Project status
Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
@@ -334,6 +347,10 @@ These companies and projects are helping Uncloud with their generous sponsorship
## ❤️ Contributors
<a href="https://trendshift.io/repositories/14069">
<img alt="Trendshift" src="https://trendshift.io/api/badge/repositories/14069" />
</a>
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
+69 -33
View File
@@ -10,68 +10,104 @@ import (
"github.com/spf13/cobra"
)
// NewBuildCommand creates a new command to build services from a Compose file.
type buildOptions struct {
cli.BuildServicesOptions
files []string
profiles []string
}
// NewBuildCommand creates a new command to build images for services from a Compose file.
func NewBuildCommand() *cobra.Command {
opts := cli.BuildOptions{}
opts := buildOptions{}
cmd := &cobra.Command{
Use: "build [FLAGS] [SERVICE...]",
Short: "Build services from a Compose file.",
Long: `Build images for services from a Compose file using local Docker.
By default, built images remain on the local Docker host. Use --push to upload them
to cluster machines or --push-registry to upload them to external registries.`,
Example: ` # Build all services that have a build section in compose.yaml.
uc build
# Build specific services that have a build section.
uc build web api
# Build services and push images to all cluster machines or service x-machines if specified.
uc build --push
# Build services and push images to specific machines.
uc build --push -m machine1,machine2
# Build services and push images to external registries (e.g., Docker Hub).
uc build --push-registry
# Build services with build arguments, pull newer base images before building, and don't use cache.
uc build --build-arg NODE_VERSION=24 --build-arg ENV=production --no-cache --pull`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
if len(args) > 0 {
opts.Services = args
}
return runBuild(cmd.Context(), uncli, opts)
},
GroupID: "service",
}
cmd.Flags().StringSliceVarP(&opts.Files, "file", "f", nil,
"One or more Compose files to build (default compose.yaml)")
cmd.Flags().StringSliceVarP(&opts.Profiles, "profile", "p", nil,
cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil,
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
cmd.Flags().BoolVar(&opts.Check, "check", false,
"Check the build configuration for services without building them.")
cmd.Flags().BoolVar(&opts.Deps, "deps", false,
"Also build services declared as dependencies of the selected services.")
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
"One or more Compose files to build. (default compose.yaml)")
cmd.Flags().StringSliceVarP(&opts.Machines, "machine", "m", nil,
"Machine names or IDs to push the built images to (requires --push).\n"+
"Can be specified multiple times or as a comma-separated list. (default is all machines or x-machines)")
cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false,
"Do not use cache when building images.")
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
"One or more Compose profiles to enable.")
cmd.Flags().BoolVarP(&opts.Push, "push", "P", false,
"Push built images to the registry after building. (default false)")
cmd.Flags().BoolVarP(&opts.NoCache, "no-cache", "n", false,
"Do not use cache when building images. (default false)")
cmd.Flags().BoolVar(&opts.Pull, "pull", false,
"Always attempt to pull newer versions of base images before building.")
cmd.Flags().BoolVar(&opts.PushCluster, "push", false,
"Upload the built images to cluster machines after building.\n"+
"Use --machine to specify which machines. (default is all machines)")
cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false,
"Upload the built images to external registries (e.g., Docker Hub) after building.")
return cmd
}
// TODO: deduplicate with a similar functino for deploy options
// projectOpts returns the project options for the Compose file(s).
func projectOptsFromBuildOpts(opts cli.BuildOptions) []composecli.ProjectOptionsFn {
projectOpts := []composecli.ProjectOptionsFn{}
if len(opts.Profiles) > 0 {
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.Profiles...))
// runBuild parses the Compose file(s) and builds the images for selected services.
func runBuild(ctx context.Context, uncli *cli.CLI, opts buildOptions) error {
// Validate push flags.
if opts.PushCluster && opts.PushRegistry {
return fmt.Errorf("cannot specify both --push and --push-registry: choose one push target")
}
return projectOpts
}
machines := cli.ExpandCommaSeparatedValues(opts.Machines)
// Special handling for an explicit "all" keyword to push to all machines.
if len(machines) == 1 && machines[0] == "all" {
machines = nil
}
opts.Machines = machines
// runBuild parses the Compose file(s), builds the services, and pushes them if requested.
func runBuild(ctx context.Context, uncli *cli.CLI, opts cli.BuildOptions) error {
projectOpts := projectOptsFromBuildOpts(opts)
project, err := compose.LoadProject(ctx, opts.Files, projectOpts...)
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
if err != nil {
return fmt.Errorf("load compose file(s): %w", err)
}
if len(opts.Services) > 0 {
project, err = project.WithSelectedServices(opts.Services)
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps)
if err != nil {
return fmt.Errorf("select services: %w", err)
return fmt.Errorf("determine services to build: %w", err)
}
}
servicesToBuild := cli.GetServicesThatNeedBuild(project)
if len(servicesToBuild) == 0 {
fmt.Println("No services to build.")
return nil
}
return cli.BuildServices(ctx, servicesToBuild, opts)
return uncli.BuildServices(ctx, project, opts.BuildServicesOptions)
}
+2 -8
View File
@@ -7,14 +7,12 @@ import (
"github.com/alecthomas/chroma/v2/quick"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type configOptions struct {
machine string
noColor bool
context string
}
func NewConfigCommand() *cobra.Command {
@@ -34,16 +32,12 @@ func NewConfigCommand() *cobra.Command {
"Name or ID of the machine to get the configuration from. (default is connected machine)")
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
"Disable syntax highlighting for the output.")
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -51,7 +45,7 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
if opts.machine != "" {
// If a specific machine is requested, use it to get the Caddy configuration.
ctx, _, err = api.ProxyMachinesContext(ctx, clusterClient, []string{opts.machine})
ctx, _, err = clusterClient.ProxyMachinesContext(ctx, []string{opts.machine})
if err != nil {
return err
}
+50 -23
View File
@@ -9,9 +9,11 @@ import (
"slices"
"strings"
"charm.land/lipgloss/v2"
"github.com/docker/cli/cli/streams"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
@@ -22,7 +24,6 @@ type deployOptions struct {
caddyfile string
image string
machines []string
context string
}
func NewDeployCommand() *cobra.Command {
@@ -44,12 +45,8 @@ func NewDeployCommand() *cobra.Command {
cmd.Flags().StringVar(&opts.image, "image", "",
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Machine names to deploy to. Can be specified multiple times or as a comma-separated "+
"list of machine names. (default is all machines)")
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context to deploy to. (default is the current context)",
)
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
"list. (default is all machines)")
return cmd
}
@@ -64,7 +61,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
caddyfile = strings.TrimSpace(string(data))
}
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -75,9 +72,11 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
if !errors.Is(err, api.ErrNotFound) {
return fmt.Errorf("inspect caddy service: %w", err)
}
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName)
fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(client.CaddyServiceName) +
tui.Faint.Render(" (not running)"))
} else {
fmt.Printf("Service: %s (%s mode)\n", svc.Name, svc.Mode)
fmt.Println(tui.Faint.Render("service: ") + tui.NameStyle.Render(svc.Name) +
tui.Faint.Render(" ("+svc.Mode+" mode)"))
// Collect unique images of all containers in the running caddy service.
images := make(map[string]struct{}, len(svc.Containers))
@@ -87,15 +86,19 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
currentImages := slices.Collect(maps.Keys(images))
if len(currentImages) > 1 {
commaSeparatedImages := strings.Join(currentImages, ", ")
fmt.Printf("Current images (multiple versions detected): %s\n", commaSeparatedImages)
formattedImages := make([]string, len(currentImages))
for i, img := range currentImages {
formattedImages[i] = tui.FormatImage(img, lipgloss.NewStyle())
}
fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") +
strings.Join(formattedImages, tui.Faint.Render(", ")))
} else {
fmt.Printf("Current image: %s\n", currentImages[0])
fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], lipgloss.NewStyle()))
}
}
if opts.image != "" {
fmt.Printf("Target image: %s\n", opts.image)
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(opts.image, tui.Green))
}
fmt.Println()
@@ -110,7 +113,8 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
}
if opts.image == "" {
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image)
fmt.Println(tui.Faint.Render("target image: ") + tui.FormatImage(d.Spec.Container.Image,
tui.Green) + tui.Faint.Render(" (latest stable)"))
}
plan, err := d.Plan(ctx)
@@ -129,18 +133,41 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
}
}
// Initialise a machine and container name resolver to properly format the plan output.
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
if err != nil {
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
fmt.Println()
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
fmt.Println()
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
deployTarget := ""
if directConn != "" {
deployTarget = directConn
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
fmt.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
deployTarget = contextName
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
fmt.Println()
}
fmt.Println()
fmt.Println("Deployment plan:")
fmt.Println(plan.Format(resolver))
fmt.Println(plan.Format())
summary := plan.FormatSummary()
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
fmt.Println()
confirmed, err := cli.Confirm()
title := "Proceed with deployment?"
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
// deploying to the wrong cluster.
if deployTarget != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with deployment to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
}
confirmed, err := tui.Confirm(title)
if err != nil {
return fmt.Errorf("confirm deployment: %w", err)
}
+79
View File
@@ -0,0 +1,79 @@
package context
import (
"fmt"
"charm.land/huh/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/spf13/cobra"
)
func NewConnectionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "connection",
Aliases: []string{"conn"},
Short: "Choose a new default connection for the current context.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return selectConnection(uncli)
},
}
return cmd
}
func selectConnection(uncli *cli.CLI) error {
if uncli.Config == nil {
return fmt.Errorf("connection management is not available: Uncloud configuration file is not being used")
}
if len(uncli.Config.Contexts) == 0 {
return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path())
}
currentCtxName := uncli.Config.CurrentContext
currentCtx, ok := uncli.Config.Contexts[currentCtxName]
if !ok {
return fmt.Errorf("current context '%s' not found", currentCtxName)
}
if len(currentCtx.Connections) == 0 {
return fmt.Errorf("no connections found in context '%s'", currentCtxName)
}
var selectedConnection int
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title("Select a default connection").
Options(buildConnectionOptions(currentCtx.Connections)...).
Value(&selectedConnection),
),
)
if err := form.Run(); err != nil {
return fmt.Errorf("select connection: %w", err)
}
currentCtx.SetDefaultConnection(selectedConnection)
selected := currentCtx.Connections[0]
if err := uncli.Config.Save(); err != nil {
return fmt.Errorf("save config: %w", err)
}
fmt.Printf("Default connection for context '%s' is now '%s'.\n", currentCtxName, selected.String())
return nil
}
func buildConnectionOptions(connections []config.MachineConnection) []huh.Option[int] {
options := make([]huh.Option[int], len(connections))
for i, conn := range connections {
key := conn.String()
opt := huh.NewOption(key, i)
if i == 0 {
opt.Key += " (default)"
opt = opt.Selected(true)
}
options[i] = opt
}
return options
}
+1 -8
View File
@@ -12,8 +12,6 @@ import (
)
func NewListCommand() *cobra.Command {
var clusterContext string
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
@@ -24,11 +22,6 @@ func NewListCommand() *cobra.Command {
},
}
cmd.Flags().StringVarP(
&clusterContext, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
@@ -51,7 +44,7 @@ func list(uncli *cli.CLI) error {
for _, name := range contextNames {
current := ""
if name == currentContext {
current = "*"
current = ""
}
connCount := len(uncli.Config.Contexts[name].Connections)
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
+1
View File
@@ -19,6 +19,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(
NewListCommand(),
NewUseCommand(),
NewConnectionCommand(),
)
return cmd
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"maps"
"slices"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
"github.com/psviderski/uncloud/internal/cli"
"github.com/spf13/cobra"
)
+120 -75
View File
@@ -4,12 +4,13 @@ import (
"context"
"errors"
"fmt"
"strings"
"os"
"charm.land/lipgloss/v2"
composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose"
"github.com/psviderski/uncloud/pkg/client/deploy"
@@ -17,13 +18,15 @@ import (
)
type deployOptions struct {
cli.BuildServicesOptions
files []string
profiles []string
services []string
noBuild bool
recreate bool
context string
skipHealth bool
yes bool
}
// NewDeployCommand creates a new command to deploy services from a Compose file.
@@ -33,26 +36,38 @@ func NewDeployCommand() *cobra.Command {
Use: "deploy [FLAGS] [SERVICE...]",
Short: "Deploy services from a Compose file.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
if len(args) > 0 {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.services = args
}
return runDeploy(cmd.Context(), uncli, opts)
},
GroupID: "service",
}
cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil,
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
cmd.Flags().BoolVar(&opts.BuildServicesOptions.Pull, "build-pull", false,
"Always attempt to pull newer versions of base images before building service images.")
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
"One or more Compose files to deploy services from. (default compose.yaml)")
cmd.Flags().BoolVar(&opts.noBuild, "no-build", false,
"Do not build new images before deploying services.")
cmd.Flags().BoolVar(&opts.BuildServicesOptions.NoCache, "no-cache", false,
"Do not use cache when building images.")
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
"One or more Compose profiles to enable.")
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context to deploy to (default is the current context)")
cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false,
"Do not build images before deploying services. (default false)")
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
"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,
"Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
// Could be useful to test a new version on a subset of machines before rolling out to all.
@@ -60,26 +75,15 @@ func NewDeployCommand() *cobra.Command {
return cmd
}
// projectOpts returns the project options for the Compose file(s).
func projectOpts(opts deployOptions) []composecli.ProjectOptionsFn {
projectOpts := []composecli.ProjectOptionsFn{}
if len(opts.profiles) > 0 {
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.profiles...))
}
return projectOpts
}
// runDeploy parses the Compose file(s) and deploys the services.
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
projectOpts := projectOpts(opts)
project, err := compose.LoadProject(ctx, opts.files, projectOpts...)
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
if err != nil {
return fmt.Errorf("load compose file(s): %w", err)
}
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
if len(opts.services) > 0 {
// Includes service dependencies by default. This is the default docker compose behavior.
project, err = project.WithSelectedServices(opts.services)
@@ -88,32 +92,72 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
}
}
servicesToBuild := cli.GetServicesThatNeedBuild(project)
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.services, false)
if err != nil {
return fmt.Errorf("determine services to build: %w", err)
}
if len(servicesToBuild) > 0 {
if opts.noBuild {
fmt.Println("Not building services as requested.")
} else {
buildOpts := cli.BuildOptions{
Push: true,
NoCache: false,
}
// Build service images without pushing them to cluster yet to not connect to the cluster twice.
opts.BuildServicesOptions.Deps = true // build dependencies as deploy includes them by default
opts.BuildServicesOptions.Services = opts.services
if err := cli.BuildServices(ctx, servicesToBuild, buildOpts); err != nil {
if err = uncli.BuildServices(ctx, project, opts.BuildServicesOptions); err != nil {
return fmt.Errorf("build services: %w", err)
}
}
fmt.Println()
}
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
var strategy deploy.Strategy
if opts.recreate {
strategy = &deploy.RollingStrategy{ForceRecreate: true}
if len(servicesToBuild) > 0 && !opts.noBuild {
// Push built service images to cluster machines one at a time.
var errs []error
for _, s := range servicesToBuild {
if s.Image == "" {
// Skip services without an image name (shouldn't happen for services with build config).
continue
}
// Push to the specified x-machines or to *all* cluster machines if not specified.
var pushOpts client.PushImageOptions
if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
pushOpts.Machines = machines
}
if len(pushOpts.Machines) == 0 {
pushOpts.AllMachines = true
}
boldStyle := lipgloss.NewStyle().Bold(true)
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
}
return nil
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
// Collect errors to try pushing all images.
if err != nil {
errs = append(errs, err)
}
}
if err = errors.Join(errs...); err != nil {
return err
}
fmt.Println()
}
strategy := &deploy.RollingStrategy{
ForceRecreate: opts.recreate,
SkipHealthMonitor: opts.skipHealth,
}
composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy)
if err != nil {
@@ -125,18 +169,47 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
return fmt.Errorf("plan deployment: %w", err)
}
if len(plan.Operations) == 0 {
if plan.IsEmpty() {
fmt.Println("Services are up to date.")
return nil
}
fmt.Println("Deployment plan:")
if err = printPlan(ctx, clusterClient, plan); err != nil {
return fmt.Errorf("print deployment plan: %w", err)
}
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
fmt.Println()
confirmed, err := cli.Confirm()
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
deployTarget := ""
if directConn != "" {
deployTarget = directConn
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
fmt.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
deployTarget = contextName
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
fmt.Println()
}
fmt.Println(plan.Format())
// Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
if !opts.yes {
if !tui.IsStdinTerminal() {
return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
}
title := "Proceed with deployment?"
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
// deploying to the wrong cluster.
if deployTarget != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with deployment to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
}
confirmed, err := tui.Confirm(title)
if err != nil {
return fmt.Errorf("confirm deployment: %w", err)
}
@@ -144,44 +217,16 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
fmt.Println("Cancelled. No changes were made.")
return nil
}
}
title := "Deploying"
if deployTarget != "" {
title += " to " + tui.NameStyle.Render(deployTarget)
}
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err := plan.Execute(ctx, clusterClient); err != nil {
return fmt.Errorf("deploy services: %w", err)
}
return nil
}, uncli.ProgressOut(), "Deploying services")
}
func printPlan(ctx context.Context, cli *client.Client, plan 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")
}, uncli.ProgressOut(), title)
}
+3 -14
View File
@@ -12,32 +12,21 @@ import (
"google.golang.org/protobuf/types/known/emptypb"
)
type releaseOptions struct {
context string
}
func NewReleaseCommand() *cobra.Command {
opts := releaseOptions{}
cmd := &cobra.Command{
Use: "release",
Short: "Release the reserved cluster domain.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return release(cmd.Context(), uncli, opts)
return release(cmd.Context(), uncli)
},
}
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func release(ctx context.Context, uncli *cli.CLI, opts releaseOptions) error {
client, err := uncli.ConnectCluster(ctx, opts.context)
func release(ctx context.Context, uncli *cli.CLI) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+1 -6
View File
@@ -19,7 +19,6 @@ const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
type reserveOptions struct {
endpoint string
context string
}
func NewReserveCommand() *cobra.Command {
@@ -36,16 +35,12 @@ func NewReserveCommand() *cobra.Command {
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
"API endpoint for the Uncloud DNS service.")
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+1 -1
View File
@@ -9,7 +9,7 @@ func NewRootCommand() *cobra.Command {
Use: "dns",
Short: "Manage cluster domain in Uncloud DNS.",
Long: "Manage cluster domain in Uncloud DNS.\n" +
"DNS commands allow you to reserve or release a unique '<id>.cluster.uncloud.run' domain for your " +
"DNS commands allow you to reserve or release a unique 'xxxxxx.uncld.dev' domain for your " +
"cluster. When reserved, Caddy service deployments will automatically update DNS records to route " +
"traffic to the services in the cluster.",
}
+3 -14
View File
@@ -10,32 +10,21 @@ import (
"github.com/spf13/cobra"
)
type showOptions struct {
context string
}
func NewShowCommand() *cobra.Command {
opts := showOptions{}
cmd := &cobra.Command{
Use: "show",
Short: "Print the cluster domain name.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return show(cmd.Context(), uncli, opts)
return show(cmd.Context(), uncli)
},
}
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
func show(ctx context.Context, uncli *cli.CLI) error {
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+309
View File
@@ -0,0 +1,309 @@
package image
import (
"context"
"fmt"
"os"
"slices"
"sort"
"strings"
"time"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/charmbracelet/colorprofile"
"github.com/containerd/platforms"
"github.com/docker/docker/api/types/image"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type listOptions struct {
machines []string
nameFilter string
}
func NewListCommand() *cobra.Command {
opts := listOptions{}
cmd := &cobra.Command{
Use: "ls [REPO:[TAG]]",
Aliases: []string{"list"},
Short: "List images on machines in the cluster.",
Long: "List images on machines in the cluster. By default, on all machines. Optionally filter by image name.",
Example: ` # List all images on all machines.
uc image ls
# List images on specific machine.
uc image ls -m machine1
# List images on multiple machines.
uc image ls -m machine1,machine2
# List images filtered by name (with any tag) on all machines.
uc image ls myapp
# List images filtered by name pattern on specific machine.
uc image ls "myapp:1.*" -m machine1`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.nameFilter = args[0]
}
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
"(default is include all machines)")
return cmd
}
// imageRow represents a single image with its metadata for display.
type imageRow struct {
id string
name string
platforms string
createdHuman string
createdUnix int64
size string
inUse string
store string
machine string
}
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
// Get all machines to create ID to name mapping.
allMachines, err := clusterClient.ListMachines(ctx, nil)
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machineIDToName := make(map[string]string)
for _, machineMember := range allMachines {
if machineMember.Machine != nil && machineMember.Machine.Id != "" && machineMember.Machine.Name != "" {
machineIDToName[machineMember.Machine.Id] = machineMember.Machine.Name
}
}
machines := cli.ExpandCommaSeparatedValues(opts.machines)
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
Machines: machines,
Name: opts.nameFilter,
})
if err != nil {
return fmt.Errorf("list images: %w", err)
}
// Collect all images from all machines.
var rows []imageRow
for _, machineImages := range clusterImages {
// Get machine name for better readability.
machineName := machineImages.Metadata.Machine
if m := allMachines.FindByNameOrID(machineName); m != nil {
machineName = m.Machine.Name
}
store := "docker"
if machineImages.ContainerdStore {
store = "containerd"
}
// Process each image for this machine.
for _, img := range machineImages.Images {
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
name := "<none>"
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
name = img.RepoTags[0]
}
imgPlatforms, _ := imagePlatforms(img)
formattedPlatforms := formatPlatforms(imgPlatforms)
created := ""
createdAt := time.Unix(img.Created, 0)
if !createdAt.IsZero() {
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
}
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
inUse := "-"
if img.Containers != -1 { // -1 means the info is not available.
if img.Containers > 0 {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
} else {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
}
}
rows = append(rows, imageRow{
id: id,
name: name,
platforms: formattedPlatforms,
createdHuman: created,
createdUnix: img.Created,
size: size,
inUse: inUse,
store: store,
machine: machineName,
})
}
}
if len(rows) == 0 {
if opts.nameFilter != "" {
fmt.Printf("No images matching '%s' found.\n", opts.nameFilter)
} else {
fmt.Println("No images found.")
}
return nil
}
// Sort images by name, then by machine name.
sort.Slice(rows, func(i, j int) bool {
if rows[i].name != rows[j].name {
return rows[i].name < rows[j].name
}
return rows[i].machine < rows[j].machine
})
// Print the images in a table format.
fmt.Println(formatImageTable(rows))
return nil
}
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
func imagePlatforms(img image.Summary) ([]string, bool) {
var formattedPlatforms []string
multiPlatform := false
for _, m := range img.Manifests {
if m.Kind != image.ManifestKindImage || !m.Available {
continue
}
if m.ID != img.ID {
// There is an image manifest that has digest different from the main image digest.
// This means the image manifest is an index or a manifest list (multi-platform image).
multiPlatform = true
}
formattedPlatforms = append(formattedPlatforms, platforms.Format(m.ImageData.Platform))
}
slices.Sort(formattedPlatforms)
return formattedPlatforms, multiPlatform
}
func formatPlatforms(platforms []string) string {
if len(platforms) == 0 {
return "-"
}
platformStyle := lipgloss.NewStyle().
BorderForeground(lipgloss.Color("152")).
Foreground(lipgloss.Color("0")).
Background(lipgloss.Color("152"))
// Use fancy pill borders only if the output is a terminal with color support.
if colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ASCII {
platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true)
}
styledPlatforms := make([]string, len(platforms))
for i, p := range platforms {
styledPlatforms[i] = platformStyle.Render(p)
}
return strings.Join(styledPlatforms, " ")
}
func formatImageTable(rows []imageRow) string {
columns := []struct {
name string
hide bool
}{
{name: "IMAGE ID"},
{name: "NAME"},
{name: "PLATFORMS"},
{name: "CREATED"},
{name: "SIZE"},
{name: "IN USE"},
{name: "STORE"},
{name: "MACHINE"},
}
// Hide the "IN USE" column if none of the images have that info available.
inUseInfoAvailable := slices.ContainsFunc(rows, func(r imageRow) bool {
return r.inUse != "-"
})
if !inUseInfoAvailable {
// Hide "IN USE" column.
columns[5].hide = true
}
t := table.New().
// Remove the default border.
Border(lipgloss.Border{}).
BorderTop(false).
BorderBottom(false).
BorderLeft(false).
BorderRight(false).
BorderHeader(false).
BorderColumn(false).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
}
// Regular style for data rows with padding.
return lipgloss.NewStyle().PaddingRight(3)
})
var headers []string
for _, col := range columns {
if !col.hide {
headers = append(headers, col.name)
}
}
t.Headers(headers...)
for _, row := range rows {
values := []string{
row.id,
row.name,
row.platforms,
row.createdHuman,
row.size,
row.inUse,
row.store,
row.machine,
}
var filteredValues []string
for i, v := range values {
if !columns[i].hide {
filteredValues = append(filteredValues, v)
}
}
t.Row(filteredValues...)
}
return t.String()
}
+94
View File
@@ -0,0 +1,94 @@
package image
import (
"context"
"fmt"
"github.com/containerd/platforms"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra"
)
type pushOptions struct {
image string
machines []string
platform string
}
func NewPushCommand() *cobra.Command {
opts := pushOptions{}
cmd := &cobra.Command{
Use: "push IMAGE",
Short: "Upload a local Docker image to the cluster.",
Long: `Upload a local Docker image to the cluster transferring only the missing layers.
The image is uploaded to all cluster machines (default) or the specified machine(s).`,
Example: ` # Push image to all machines in the cluster.
uc image push myapp:latest
# Push image to specific machine.
uc image push myapp:latest -m machine1
# Push image to multiple machines.
uc image push myapp:latest -m machine1,machine2,machine3
# Push a specific platform of a multi-platform image.
uc image push myapp:latest --platform linux/amd64`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.image = args[0]
return push(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Machine names or IDs to push the image to. Can be specified multiple times or as a comma-separated list. "+
"(default is all machines)")
cmd.Flags().StringVar(
&opts.platform, "platform", "",
"Push a specific platform of a multi-platform image (e.g., linux/amd64, linux/arm64).\n"+
"Local Docker must be configured to use containerd image store to support multi-platform images.",
)
return cmd
}
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
machines := cli.ExpandCommaSeparatedValues(opts.machines)
pushOpts := client.PushImageOptions{}
// Special handling for an explicit "all" keyword to push to all machines.
if len(machines) == 1 && machines[0] == "all" {
pushOpts.AllMachines = true
} else if len(machines) > 0 {
pushOpts.Machines = machines
} else {
// Default is to push to all machines in the cluster.
pushOpts.AllMachines = true
}
if opts.platform != "" {
p, err := platforms.Parse(opts.platform)
if err != nil {
return fmt.Errorf("invalid platform '%s': %w", opts.platform, err)
}
pushOpts.Platform = &p
}
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err = clusterClient.PushImage(ctx, opts.image, pushOpts); err != nil {
return fmt.Errorf("push image to cluster: %w", err)
}
return nil
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", opts.image))
}
+19
View File
@@ -0,0 +1,19 @@
package image
import (
"github.com/spf13/cobra"
)
func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "image",
Short: "Manage images on machines in the cluster.",
}
cmd.AddCommand(
NewListCommand(),
NewPushCommand(),
)
return cmd
}
+19
View File
@@ -0,0 +1,19 @@
package main
import (
"strings"
"github.com/psviderski/uncloud/cmd/uncloud/image"
"github.com/spf13/cobra"
)
// NewImagesCommand returns the 'image ls' command modified to work as 'images'.
func NewImagesCommand() *cobra.Command {
listCmd := image.NewListCommand()
listCmd.Use = "images [IMAGE]"
// Remove 'list' alias since this command is already an alias.
listCmd.Aliases = nil
listCmd.Example = strings.ReplaceAll(listCmd.Example, "uc image ls", "uc images")
return listCmd
}
+108 -45
View File
@@ -5,27 +5,31 @@ import (
"errors"
"fmt"
"net/netip"
"strings"
"time"
"github.com/cenkalti/backoff/v4"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type addOptions struct {
name string
noCaddy bool
noInstall bool
publicIP string
sshKey string
context string
version string
wgEndpoints []string
yes bool
}
func NewAddCommand() *cobra.Command {
@@ -33,11 +37,24 @@ func NewAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add [USER@]HOST[:PORT]",
Short: "Add a remote machine to a cluster.",
Long: `Add a new machine to an existing Uncloud cluster.
Connection methods:
ssh://user@host - Use built-in SSH library (default, no prefix required)
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
uncli := cmd.Context().Value("cli").(*cli.CLI)
user, host, port, err := config.SSHDestination(args[0]).Parse()
// Determine if SSH CLI needs to be used and strip scheme
destination := args[0]
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh://")
user, host, port, err := config.SSHDestination(destination).Parse()
if err != nil {
return fmt.Errorf("parse remote machine: %w", err)
}
@@ -46,6 +63,7 @@ func NewAddCommand() *cobra.Command {
Host: host,
Port: port,
KeyPath: opts.sshKey,
UseSSHCLI: useSSHCLI,
}
return add(cmd.Context(), uncli, remoteMachine, opts)
@@ -56,6 +74,11 @@ func NewAddCommand() *cobra.Command {
&opts.noCaddy, "no-caddy", false,
"Don't deploy Caddy reverse proxy service to the machine.",
)
cmd.Flags().BoolVar(
&opts.noInstall, "no-install", false,
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
"Assumes they're already installed and running.",
)
cmd.Flags().StringVar(
&opts.publicIP, "public-ip", "auto",
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
@@ -70,10 +93,17 @@ func NewAddCommand() *cobra.Command {
&opts.version, "version", "latest",
"Version of the Uncloud daemon to install on the machine.",
)
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context to add the machine to. (default is the current context)",
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,
"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]")
return cmd
}
@@ -93,13 +123,24 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
publicIP = &ip
}
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
Context: opts.context,
addOpts := cli.AddMachineOptions{
MachineName: opts.name,
PublicIP: publicIP,
RemoteMachine: remoteMachine,
SkipInstall: opts.noInstall,
Version: opts.version,
})
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 {
return err
}
@@ -110,25 +151,41 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
return nil
}
// Wait for the cluster to be initialised to be able to deploy the Caddy service.
fmt.Println("Waiting for the machine to be ready...")
fmt.Println()
if err = waitClusterInitialised(ctx, machineClient); err != nil {
return fmt.Errorf("wait for cluster to be initialised on machine: %w", err)
// Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service.
err = spinner.New().
Title(" Waiting for the machine to join the cluster...").
Type(spinner.MiniDot).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
}).
Run()
if err != nil {
return fmt.Errorf("wait for machine to join the cluster: %w", err)
}
fmt.Println("Machine joined the cluster.")
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
// that may cause a small downtime.
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
// use the deployed image version. Otherwise, use the latest version.
// use the deployed image version.
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
// issues accessing the Machine API of existing machines in the cluster.
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
caddyImage := ""
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
if err != nil {
if !errors.Is(err, api.ErrNotFound) {
if errors.Is(err, api.ErrNotFound) {
// Caddy service is not deployed.
return nil
}
return fmt.Errorf("inspect caddy service: %w", err)
}
} else {
caddyImage = caddySvc.Containers[0].Container.Config.Image
// Find the latest created container and use its image.
var latestCreated time.Time
@@ -142,47 +199,53 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
caddyImage = c.Container.Config.Image
}
}
}
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
// that may cause a small downtime.
fmt.Println()
fmt.Println("Preparing Caddy deployment...")
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
if err != nil {
return fmt.Errorf("create caddy deployment: %w", err)
}
plan, err := d.Plan(ctx)
if err != nil {
return fmt.Errorf("plan caddy deployment: %w", err)
}
if len(plan.Operations) == 0 {
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
} else {
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
fmt.Println()
fmt.Print(plan.Format())
summary := plan.FormatSummary()
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
fmt.Println()
if !opts.yes {
confirmed, err := tui.Confirm("Proceed with deployment?")
if err != nil {
return fmt.Errorf("confirm deployment: %w", err)
}
if !confirmed {
fmt.Println("Cancelled. No changes were made.")
return nil
}
}
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if _, err = d.Run(ctx); err != nil {
return fmt.Errorf("deploy caddy: %w", err)
}
return nil
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode))
if err != nil {
return err
}
}
fmt.Println()
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
}
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
boff := backoff.WithContext(backoff.NewExponentialBackOff(
backoff.WithMaxInterval(1*time.Second),
backoff.WithMaxElapsedTime(5*time.Minute),
), ctx)
check := func() error {
_, err := client.ListMachines(ctx, nil)
if err == nil {
return nil
}
statusErr := status.Convert(err)
if statusErr.Code() == codes.FailedPrecondition {
return err
}
return backoff.Permanent(err)
}
return backoff.Retry(check, boff)
}
+103 -15
View File
@@ -4,7 +4,11 @@ import (
"context"
"fmt"
"net/netip"
"strings"
"time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uncloud/dns"
@@ -12,36 +16,65 @@ import (
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/cluster"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type initOptions struct {
context string
dnsEndpoint string
name string
network string
noCaddy bool
noDNS bool
noInstall bool
publicIP string
sshKey string
version string
context string
wgEndpoints []string
yes bool
}
func NewInitCommand() *cobra.Command {
opts := initOptions{}
cmd := &cobra.Command{
Use: "init [USER@HOST:PORT]",
Use: "init [schema://]USER@HOST[:PORT]",
Short: "Initialise a new cluster with a remote machine as the first member.",
// TODO: include usage examples of initialising a remote machine.
Long: `Initialise a new cluster by setting up a remote machine as the first member.
This command creates a new context in your Uncloud config to manage the cluster.
Connection methods:
ssh://user@host - Use built-in SSH library (default, no prefix required)
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
Example: ` # Initialise a new cluster with default settings.
uc machine init root@<your-server-ip>
# Initialise with a context name 'prod' in the Uncloud config (~/.config/uncloud/config.yaml) and machine name 'vps1'.
uc machine init root@<your-server-ip> -c prod -n vps1
# Initialise with a non-root user and custom SSH port and key.
uc machine init ubuntu@<your-server-ip>:2222 -i ~/.ssh/mykey
# Initialise without Caddy (no reverse proxy) and without an automatically managed domain name (xxxxxx.uncld.dev).
# You can deploy Caddy with 'uc caddy deploy' and reserve a domain with 'uc dns reserve' later.
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
// TODO: support initialising a cluster on the local machine.
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
uncli := cmd.Context().Value("cli").(*cli.CLI)
var remoteMachine *cli.RemoteMachine
if len(args) > 0 {
user, host, port, err := config.SSHDestination(args[0]).Parse()
// Determine if SSH CLI is requested and strip scheme
destination := args[0]
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh://")
user, host, port, err := config.SSHDestination(destination).Parse()
if err != nil {
return fmt.Errorf("parse remote machine: %w", err)
}
@@ -50,12 +83,18 @@ func NewInitCommand() *cobra.Command {
Host: host,
Port: port,
KeyPath: opts.sshKey,
UseSSHCLI: useSSHCLI,
}
}
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
},
}
cmd.Flags().StringVarP(
&opts.context, "context", "c", cli.DefaultContextName,
"Name of the new context to be created in the Uncloud config to manage the cluster.",
)
cmd.Flags().StringVar(&opts.dnsEndpoint, "dns-endpoint", dns.DefaultUncloudDNSAPIEndpoint,
"API endpoint for the Uncloud DNS service.")
cmd.Flags().StringVarP(
@@ -68,11 +107,16 @@ func NewInitCommand() *cobra.Command {
)
cmd.Flags().BoolVar(
&opts.noCaddy, "no-caddy", false,
"Don't deploy Caddy reverse proxy service to the machine.",
"Don't deploy Caddy reverse proxy service to the machine. You can deploy it later with 'uc caddy deploy'.",
)
cmd.Flags().BoolVar(
&opts.noDNS, "no-dns", false,
"Don't reserve a cluster domain in Uncloud DNS.",
"Don't reserve a cluster domain in Uncloud DNS. You can reserve it later with 'uc dns reserve'.",
)
cmd.Flags().BoolVar(
&opts.noInstall, "no-install", false,
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
"Assumes they're already installed and running.",
)
cmd.Flags().StringVar(
&opts.publicIP, "public-ip", "auto",
@@ -88,15 +132,30 @@ func NewInitCommand() *cobra.Command {
&opts.version, "version", "latest",
"Version of the Uncloud daemon to install on the machine.",
)
cmd.Flags().StringVarP(
&opts.context, "context", "c", "default",
"Name of the created context for the initialised cluster in the Uncloud config.",
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,
"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]")
return cmd
}
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
if uncli.Config == nil {
// Config is nil when connecting directly to a remote machine (--connect) without using Uncloud config.
return fmt.Errorf(
"do not 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)
if err != nil {
return fmt.Errorf("parse network CIDR: %w", err)
@@ -115,27 +174,56 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
}
publicIP = &ip
}
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{
initOpts := cli.InitClusterOptions{
Context: opts.context,
MachineName: opts.name,
Network: netPrefix,
PublicIP: publicIP,
RemoteMachine: remoteMachine,
SkipInstall: opts.noInstall,
Version: opts.version,
})
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 {
return err
}
defer client.Close()
// Since the cluster API needs a few moments to become ready after cluster initialisation,
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
err = spinner.New().
Title(" Waiting for the cluster to be ready...").
Type(spinner.MiniDot).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
return client.WaitClusterReady(ctx, 1*time.Minute)
}).
Run()
if err != nil {
return fmt.Errorf("wait for cluster to be ready: %w", err)
}
fmt.Println("Cluster is ready.")
if opts.noCaddy && opts.noDNS {
return nil
}
// Deploy the Caddy service to the initialised machine.
// The creation of a deployment plan talks to cluster API. Since the API needs a few moments to become available
// after cluster initialisation, we keep the user informed during this wait.
fmt.Println("Waiting for the machine to be ready...")
fmt.Println()
if !opts.noDNS {
+6 -11
View File
@@ -14,25 +14,20 @@ import (
)
func NewListCommand() *cobra.Command {
var contextName string
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List machines in a cluster.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, contextName)
return list(cmd.Context(), uncli)
},
}
cmd.Flags().StringVarP(
&contextName, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
client, err := uncli.ConnectCluster(ctx, clusterName)
func list(ctx context.Context, uncli *cli.CLI) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -46,7 +41,7 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
// Print the list of machines in a table format.
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
// Print header.
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS"); err != nil {
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS\tMACHINE ID"); err != nil {
return fmt.Errorf("write header: %w", err)
}
// Print rows.
@@ -68,8 +63,8 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
}
if _, err = fmt.Fprintf(
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
strings.Join(endpoints, ", "),
tw, "%s\t%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
strings.Join(endpoints, ", "), member.Machine.Id,
); err != nil {
return fmt.Errorf("write row: %w", err)
}
+3 -8
View File
@@ -9,7 +9,6 @@ import (
)
func NewRenameCommand() *cobra.Command {
var contextName string
cmd := &cobra.Command{
Use: "rename OLD_NAME NEW_NAME",
Short: "Rename a machine in the cluster.",
@@ -20,18 +19,14 @@ configuration including network settings, public IP, and cluster membership.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return rename(cmd.Context(), uncli, contextName, args[0], args[1])
return rename(cmd.Context(), uncli, args[0], args[1])
},
}
cmd.Flags().StringVarP(
&contextName, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func rename(ctx context.Context, uncli *cli.CLI, contextName, oldName, newName string) error {
client, err := uncli.ConnectCluster(ctx, contextName)
func rename(ctx context.Context, uncli *cli.CLI, oldName, newName string) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return err
}
+21 -10
View File
@@ -9,11 +9,12 @@ import (
"strings"
"sync"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/tree"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/tree"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
@@ -22,7 +23,6 @@ import (
type removeOptions struct {
noReset bool
yes bool
context string
}
func NewRmCommand() *cobra.Command {
@@ -39,8 +39,6 @@ func NewRmCommand() *cobra.Command {
},
}
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)")
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Do not prompt for confirmation before removing the machine.")
cmd.Flags().BoolVar(&opts.noReset, "no-reset", false,
@@ -51,14 +49,14 @@ func NewRmCommand() *cobra.Command {
func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOptions) error {
// TODO: automatically choose a connection to the machine that is not being removed.
client, err := uncli.ConnectCluster(ctx, opts.context)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer client.Close()
// Verify the machine exists and list all service containers on it including stopped ones.
mctx, machines, err := api.ProxyMachinesContext(ctx, client, []string{nameOrID})
mctx, machines, err := client.ProxyMachinesContext(ctx, []string{nameOrID})
if err != nil {
return err
}
@@ -121,7 +119,7 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
}
if !opts.yes {
confirmed, err := cli.Confirm()
confirmed, err := tui.Confirm("")
if err != nil {
return fmt.Errorf("confirm removal: %w", err)
}
@@ -155,8 +153,21 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
}
}
// TODO: remove the connection to the machine from the uncloud config if it exists. We need a way to associate
// the machine with its connection in the config, e.g. by storing the machine name in the connection metadata.
// Remove the connection to the machine from the uncloud config if it exists.
if uncli.Config != nil {
contextName := uncli.ContextOverrideOrCurrent()
if context, ok := uncli.Config.Contexts[contextName]; ok {
for i, c := range context.Connections {
if c.MachineID == m.Id {
context.Connections = slices.Delete(context.Connections, i, i+1)
break
}
}
if err := uncli.Config.Save(); err != nil {
return fmt.Errorf("save config: %w", err)
}
}
}
// TODO: If Caddy was running on this machine and a cluster domain is reserved,
// let the user know that the DNS records should be updated.
+1 -2
View File
@@ -8,7 +8,7 @@ func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "machine",
Aliases: []string{"m"},
Short: "Manage machines in an Uncloud cluster.",
Short: "Manage machines in the cluster.",
}
cmd.AddCommand(
NewAddCommand(),
@@ -17,7 +17,6 @@ func NewRootCommand() *cobra.Command {
NewRenameCommand(),
NewRmCommand(),
NewUpdateCommand(),
NewTokenCommand(),
)
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
}
+61 -15
View File
@@ -4,30 +4,43 @@ import (
"context"
"fmt"
"net/netip"
"strings"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/spf13/cobra"
)
type updateOptions struct {
name string
publicIP string
context string
wgEndpoints []string
}
func NewUpdateCommand() *cobra.Command {
opts := updateOptions{}
cmd := &cobra.Command{
Use: "update",
Use: "update MACHINE [flags]",
Short: "Update machine configuration in the cluster.",
Long: `Update machine configuration in the cluster.
This command allows setting various machine properties including:
- Machine name (--name)
- Public IP address (--public-ip)
Change the name, public IP address, or WireGuard endpoints of an existing machine.
At least one flag must be specified to perform an update.`,
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),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
@@ -41,23 +54,27 @@ At least one flag must be specified to perform an update operation.`,
)
cmd.Flags().StringVar(
&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().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)",
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
}
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
if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") {
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip)")
// Check if at least one flag was explicitly set.
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, --wg-endpoint)")
}
client, err := uncli.ConnectCluster(ctx, opts.context)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return err
}
@@ -92,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
updatedMachine, err := client.UpdateMachine(ctx, req)
if err != nil {
@@ -118,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))
}
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 {
fmt.Printf(" %s\n", change)
}
+65 -17
View File
@@ -6,15 +6,19 @@ import (
"net/netip"
"strings"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
"github.com/psviderski/uncloud/cmd/uncloud/dns"
"github.com/psviderski/uncloud/cmd/uncloud/image"
"github.com/psviderski/uncloud/cmd/uncloud/machine"
"github.com/psviderski/uncloud/cmd/uncloud/service"
"github.com/psviderski/uncloud/cmd/uncloud/volume"
"github.com/psviderski/uncloud/cmd/uncloud/wg"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/fs"
"github.com/psviderski/uncloud/internal/log"
"github.com/psviderski/uncloud/internal/version"
"github.com/spf13/cobra"
)
@@ -22,17 +26,24 @@ import (
type globalOptions struct {
configPath string
connect string
context string
}
func main() {
log.InitLoggerFromEnv()
opts := globalOptions{}
cmd := &cobra.Command{
Use: "uc",
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.",
Version: version.String(),
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
cli.BindEnvToFlag(cmd, "context", "UNCLOUD_CONTEXT")
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
var conn *config.MachineConnection
if opts.connect != "" {
if strings.HasPrefix(opts.connect, "tcp://") {
@@ -43,11 +54,17 @@ func main() {
conn = &config.MachineConnection{
TCP: &addrPort,
}
} else {
dest := opts.connect
if strings.HasPrefix(dest, "ssh://") {
dest = dest[len("ssh://"):]
} else if strings.HasPrefix(opts.connect, "ssh+cli://") {
dest := opts.connect[len("ssh+cli://"):]
conn = &config.MachineConnection{
SSHCLI: config.SSHDestination(dest),
}
} else if strings.HasPrefix(opts.connect, "unix://") {
conn = &config.MachineConnection{
Unix: opts.connect[len("unix://"):],
}
} else {
dest := strings.TrimPrefix(opts.connect, "ssh://")
conn = &config.MachineConnection{
SSH: config.SSHDestination(dest),
}
@@ -55,7 +72,7 @@ func main() {
}
configPath := fs.ExpandHomeDir(opts.configPath)
uncli, err := cli.New(configPath, conn)
uncli, err := cli.New(configPath, conn, opts.context)
if err != nil {
return fmt.Errorf("initialise CLI: %w", err)
}
@@ -65,29 +82,60 @@ func main() {
}
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
"Connect to a remote cluster machine without using the Uncloud configuration file.\n"+
"Format: [ssh://]user@host[:port] or tcp://host:port")
// TODO: allow to override using UNCLOUD_CONFIG env var.
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
"Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock")
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
"Path to the Uncloud configuration file.")
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
// TODO: make --context a global flag and pass it as a value of the command context.
cmd.PersistentFlags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]")
// Set custom help function to show links to docs and Discord only for the root 'uc' command.
defaultHelpFunc := cmd.HelpFunc()
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
defaultHelpFunc(c, args)
// Only show links for the root 'uc' command.
if c.Name() == "uc" {
urlStyle := lipgloss.NewStyle().
Underline(true).
Foreground(lipgloss.Color("12")) // light blue
fmt.Fprintln(c.OutOrStdout())
fmt.Fprintf(c.OutOrStdout(), "Learn more about Uncloud: %s\n",
urlStyle.Render("https://uncloud.run/docs"))
fmt.Fprintf(c.OutOrStdout(), "Join our Discord community: %s\n",
urlStyle.Render("https://uncloud.run/discord"))
}
})
cmd.AddGroup(&cobra.Group{
ID: "service",
Title: "Deploy and manage services:",
})
cmd.AddCommand(
NewBuildCommand(),
NewDeployCommand(),
NewDocsCommand(),
NewBuildCommand(),
NewImagesCommand(),
NewPsCommand(),
caddy.NewRootCommand(),
cmdcontext.NewRootCommand(),
dns.NewRootCommand(),
image.NewRootCommand(),
machine.NewRootCommand(),
service.NewRootCommand(),
service.NewInspectCommand(),
service.NewListCommand(),
service.NewRmCommand(),
service.NewRunCommand(),
service.NewScaleCommand(),
service.NewExecCommand("service"),
service.NewInspectCommand("service"),
service.NewListCommand("service"),
service.NewLogsCommand("service"),
service.NewRmCommand("service"),
service.NewRunCommand("service"),
service.NewScaleCommand("service"),
service.NewStartCommand("service"),
service.NewStopCommand("service"),
volume.NewRootCommand(),
wg.NewRootCommand(),
)
cobra.CheckErr(cmd.Execute())
}
+290
View File
@@ -0,0 +1,290 @@
package main
import (
"context"
"fmt"
"sort"
"time"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/spf13/cobra"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/client"
)
const (
sortByService = "service"
sortByMachine = "machine"
sortByHealth = "health"
)
type containerHighlight int
const (
highlightDanger containerHighlight = iota
highlightWarning
highlightSuccess
highlightNormal
)
type psOptions struct {
sortBy string
}
func NewPsCommand() *cobra.Command {
opts := psOptions{}
cmd := &cobra.Command{
Use: "ps",
Short: "List all service containers.",
Long: `List all service containers across all machines in the cluster.
This command provides a comprehensive overview of all running containers that are part of a service,
making it easy to see the distribution and status of containers across the cluster.`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
if opts.sortBy != sortByService && opts.sortBy != sortByMachine && opts.sortBy != sortByHealth {
return fmt.Errorf("invalid value for --sort: %q, must be one of '%s', '%s' or '%s'", opts.sortBy,
sortByService, sortByMachine, sortByHealth)
}
return runPs(cmd.Context(), uncli, opts)
},
GroupID: "service",
}
cmd.Flags().StringVarP(&opts.sortBy, "sort", "s", sortByService,
"Sort containers by 'service', 'machine', or 'health'.")
return cmd
}
type containerInfo struct {
serviceName string
machineName string
id string
name string
image string
status string
highlight containerHighlight
created time.Time
ip string
}
func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
var containers []containerInfo
err = spinner.New().
Title(" Collecting container info...").
Type(spinner.MiniDot).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
ActionWithErr(func(ctx context.Context) error {
containers, err = collectContainers(ctx, clusterClient)
return err
}).
Run()
if err != nil {
return fmt.Errorf("collect containers: %w", err)
}
// Sort the containers based on the sorting option.
sort.SliceStable(containers, func(i, j int) bool {
a, b := containers[i], containers[j]
switch opts.sortBy {
case sortByHealth:
if a.highlight != b.highlight {
return a.highlight < b.highlight
}
if a.serviceName != b.serviceName {
return a.serviceName < b.serviceName
}
case sortByMachine:
if a.machineName != b.machineName {
return a.machineName < b.machineName
}
if a.serviceName != b.serviceName {
return a.serviceName < b.serviceName
}
default: // sortByService
if a.serviceName != b.serviceName {
return a.serviceName < b.serviceName
}
}
// Fallback to creation time (newest first).
return a.created.After(b.created)
})
return printContainers(containers)
}
func printContainers(containers []containerInfo) error {
t := table.New().
// Remove the default border.
Border(lipgloss.Border{}).
BorderTop(false).
BorderBottom(false).
BorderLeft(false).
BorderRight(false).
BorderHeader(false).
BorderColumn(false).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
}
// Regular style for data rows with padding.
return lipgloss.NewStyle().PaddingRight(3)
})
t.Headers("SERVICE", "CONTAINER ID", "CONTAINER NAME", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
for _, ctr := range containers {
id := ctr.id
if len(id) > 12 {
id = id[:12]
}
created := units.HumanDuration(time.Now().UTC().Sub(ctr.created)) + " ago"
var statusStyle lipgloss.Style
switch ctr.highlight {
case highlightSuccess:
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green
case highlightDanger:
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Red
case highlightWarning:
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // Yellow
default:
statusStyle = lipgloss.NewStyle() // Default
}
t.Row(
ctr.serviceName,
id,
ctr.name,
ctr.image,
created,
statusStyle.Render(ctr.status),
ctr.ip,
ctr.machineName,
)
}
fmt.Println(t)
return nil
}
func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo, error) {
listCtx, machines, err := cli.ProxyMachinesContext(ctx, nil)
if err != nil {
return nil, fmt.Errorf("proxy machines context: %w", err)
}
// Create a map of IP to machine name for resolving response metadata
machinesNamesByIP := make(map[string]string)
for _, m := range machines {
if addr, err := m.Machine.Network.ManagementIp.ToAddr(); err == nil {
machinesNamesByIP[addr.String()] = m.Machine.Name
}
}
// List all service containers across all machines in the cluster.
machineContainers, err := cli.Docker.ListServiceContainers(
listCtx, "", container.ListOptions{All: true},
)
if err != nil {
return nil, fmt.Errorf("list service containers: %w", err)
}
var containers []containerInfo
for _, msc := range machineContainers {
// Metadata can be nil if the request was broadcasted to only one machine.
if msc.Metadata == nil && len(machineContainers) > 1 {
return nil, fmt.Errorf("something went wrong with gRPC proxy: metadata is missing for a machine response")
}
machineName := "unknown"
if msc.Metadata != nil {
var ok bool
machineName, ok = machinesNamesByIP[msc.Metadata.Machine]
if !ok {
// Fallback to machine's IP as name.
machineName = msc.Metadata.Machine
}
} else {
// Fallback to the first available machine name.
if len(machines) > 0 {
machineName = machines[0].Machine.Name
}
}
if msc.Metadata != nil && msc.Metadata.Error != "" {
tui.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
msc.Metadata.Error))
continue
}
for _, ctr := range msc.Containers {
if ctr.Container.State == nil || ctr.Container.Config == nil {
continue
}
status, err := ctr.Container.HumanState()
if err != nil {
return nil, fmt.Errorf("get human state for container %s: %w", ctr.Container.ID, err)
}
var highlight containerHighlight
healthStatus := ""
if ctr.Container.State.Health != nil {
healthStatus = ctr.Container.State.Health.Status
}
if healthStatus == container.Unhealthy || ctr.Container.State.Status == "dead" || ctr.Container.State.OOMKilled || ctr.Container.State.Dead {
highlight = highlightDanger
} else if healthStatus == container.Healthy {
highlight = highlightSuccess
} else if ctr.Container.State.Status == "running" {
highlight = highlightNormal
} else { // Other non-critical but noteworthy states
highlight = highlightWarning
}
created, _ := time.Parse(time.RFC3339Nano, ctr.Container.Created)
ip := ctr.Container.UncloudNetworkIP()
ipStr := ""
// The container might not have an IP if it's not running or uses the host network.
if ip.IsValid() {
ipStr = ip.String()
}
info := containerInfo{
serviceName: ctr.ServiceName(),
machineName: machineName,
id: ctr.Container.ID,
name: ctr.Container.Name,
image: ctr.Container.Config.Image,
status: status,
highlight: highlight,
created: created,
ip: ipStr,
}
containers = append(containers, info)
}
}
return containers, nil
}
+442
View File
@@ -0,0 +1,442 @@
package main
import (
"context"
"encoding/json"
"net/netip"
"testing"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/docker"
"github.com/psviderski/uncloud/pkg/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"
)
// mockDockerClient implements pb.DockerClient
type mockDockerClient struct {
pb.DockerClient // Embed to avoid implementing all methods
listResp *pb.ListServiceContainersResponse
listErr error
}
func (m *mockDockerClient) ListServiceContainers(ctx context.Context, in *pb.ListServiceContainersRequest, opts ...grpc.CallOption) (*pb.ListServiceContainersResponse, error) {
return m.listResp, m.listErr
}
// mockClusterClient implements pb.ClusterClient
type mockClusterClient struct {
pb.ClusterClient // Embed to avoid implementing all methods
machinesResp *pb.ListMachinesResponse
machinesErr error
}
func (m *mockClusterClient) ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*pb.ListMachinesResponse, error) {
return m.machinesResp, m.machinesErr
}
func TestCollectContainers_NilMetadata(t *testing.T) {
// Setup container data
containerData := map[string]any{
"Id": "container1",
"Name": "test-container",
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON, err := json.Marshal(containerData)
require.NoError(t, err)
serviceSpecJSON, err := json.Marshal(map[string]any{})
require.NoError(t, err)
// Setup mocks
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: nil, // Simulating the issue: nil metadata
Containers: []*pb.ServiceContainer{
{
Container: containerJSON,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
machineIP := "10.0.0.1"
mockCluster := &mockClusterClient{
machinesResp: &pb.ListMachinesResponse{
Machines: []*pb.MachineMember{
{
Machine: &pb.MachineInfo{
Name: "machine-1",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr(machineIP)),
},
},
State: pb.MachineMember_UP,
},
},
},
}
// Construct client with mocks
cli := &client.Client{
Docker: &docker.Client{
GRPCClient: mockDocker,
},
ClusterClient: mockCluster,
}
// Execute
containers, err := collectContainers(context.Background(), cli)
require.NoError(t, err)
// Verify
assert.Len(t, containers, 1)
if len(containers) > 0 {
c := containers[0]
assert.Equal(t, "container1", c.id)
assert.Equal(t, "test-container", c.name)
assert.Equal(t, "machine-1", c.machineName, "Should fall back to the single machine name when metadata is nil")
}
}
func TestCollectContainers_NilMetadata_MultipleMachines_Error(t *testing.T) {
// If we have multiple machines but receive nil metadata, it should return an error as it is ambiguous
// Setup container data
containerData1 := map[string]any{
"Id": "container1",
}
containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]any{
"Id": "container2",
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]any{})
// Setup mocks
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: nil, // Nil metadata
Containers: []*pb.ServiceContainer{
{
Container: containerJSON1,
ServiceSpec: serviceSpecJSON,
},
},
},
{
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
Containers: []*pb.ServiceContainer{
{
Container: containerJSON2,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
mockCluster := &mockClusterClient{
machinesResp: &pb.ListMachinesResponse{
Machines: []*pb.MachineMember{
{
Machine: &pb.MachineInfo{
Name: "machine-1",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
},
},
},
{
Machine: &pb.MachineInfo{
Name: "machine-2",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
},
},
},
},
},
}
// Construct client with mocks
cli := &client.Client{
Docker: &docker.Client{
GRPCClient: mockDocker,
},
ClusterClient: mockCluster,
}
// Execute
_, err := collectContainers(context.Background(), cli)
require.Error(t, err)
assert.Contains(t, err.Error(), "metadata is missing for a machine response")
}
func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
// Verify correct mapping of containers to machines when metadata is present
// Setup container data
containerData1 := map[string]any{
"Id": "container1",
"Name": "container-1",
"Config": map[string]any{
"Image": "image-1",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]any{
"Id": "container2",
"Name": "container-2",
"Config": map[string]any{
"Image": "image-2",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]any{})
// Setup mocks
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: &pb.Metadata{Machine: "10.0.0.1"},
Containers: []*pb.ServiceContainer{
{
Container: containerJSON1,
ServiceSpec: serviceSpecJSON,
},
},
},
{
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
Containers: []*pb.ServiceContainer{
{
Container: containerJSON2,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
mockCluster := &mockClusterClient{
machinesResp: &pb.ListMachinesResponse{
Machines: []*pb.MachineMember{
{
Machine: &pb.MachineInfo{
Name: "machine-1",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
},
},
},
{
Machine: &pb.MachineInfo{
Name: "machine-2",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
},
},
},
},
},
}
cli := &client.Client{
Docker: &docker.Client{GRPCClient: mockDocker},
ClusterClient: mockCluster,
}
containers, err := collectContainers(context.Background(), cli)
require.NoError(t, err)
assert.Len(t, containers, 2)
// Order is not guaranteed by the map iteration in logic or parallel fetch (though here it's mocked sequential),
// but collectContainers just appends.
// We'll find them by ID.
for _, c := range containers {
if c.id == "container1" {
assert.Equal(t, "machine-1", c.machineName)
} else if c.id == "container2" {
assert.Equal(t, "machine-2", c.machineName)
} else {
t.Errorf("unexpected container id: %s", c.id)
}
}
}
func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
// Case: 1 msc with nil metadata but no machines at all
containerData := map[string]any{
"Id": "container1",
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: nil,
Containers: []*pb.ServiceContainer{
{
Container: containerJSON,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
// No machines in cluster response
mockCluster := &mockClusterClient{
machinesResp: &pb.ListMachinesResponse{
Machines: []*pb.MachineMember{},
},
}
cli := &client.Client{
Docker: &docker.Client{GRPCClient: mockDocker},
ClusterClient: mockCluster,
}
containers, err := collectContainers(context.Background(), cli)
require.NoError(t, err)
assert.Len(t, containers, 1)
if len(containers) > 0 {
assert.Equal(t, "unknown", containers[0].machineName)
}
}
func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) {
// Case: msc with metadata that is not in the IP-to-name mapping
containerData := map[string]any{
"Id": "container1",
"Config": map[string]any{
"Image": "test-image",
},
"State": map[string]any{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]any{
"Networks": map[string]any{},
},
}
containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]any{})
mockDocker := &mockDockerClient{
listResp: &pb.ListServiceContainersResponse{
Messages: []*pb.MachineServiceContainers{
{
Metadata: &pb.Metadata{Machine: "10.0.0.99"}, // Unknown IP
Containers: []*pb.ServiceContainer{
{
Container: containerJSON,
ServiceSpec: serviceSpecJSON,
},
},
},
},
},
}
mockCluster := &mockClusterClient{
machinesResp: &pb.ListMachinesResponse{
Machines: []*pb.MachineMember{
{
Machine: &pb.MachineInfo{
Name: "machine-1",
Network: &pb.NetworkConfig{
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
},
},
},
},
},
}
cli := &client.Client{
Docker: &docker.Client{GRPCClient: mockDocker},
ClusterClient: mockCluster,
}
containers, err := collectContainers(context.Background(), cli)
require.NoError(t, err)
assert.Len(t, containers, 1)
if len(containers) > 0 {
// Should fallback to the IP/string in metadata
assert.Equal(t, "10.0.0.99", containers[0].machineName)
}
}
+134
View File
@@ -0,0 +1,134 @@
package service
import (
"context"
"fmt"
"os"
"github.com/docker/cli/cli/streams"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type execCliOptions struct {
detach bool
interactive bool
noTty bool
containerId string
}
var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
func NewExecCommand(groupID string) *cobra.Command {
opts := execCliOptions{}
execCmd := &cobra.Command{
Use: "exec [OPTIONS] SERVICE [COMMAND ARGS...]",
Short: "Execute a command in a running service container.",
Long: `Execute a command (interactive shell by default) in a running container within a service.
If the service has multiple replicas and no container ID is specified, the command will be executed in a random container.
`,
Example: `
# Start an interactive shell ("bash" or "sh" will be tried by default)
uc exec web-service
# Start an interactive shell with explicit command
uc exec web-service /bin/zsh
# List files in the specific container of the service; --container accepts full ID or a (unique) prefix
uc exec --container d792e web-service ls -la
# Pipe input to a command inside the service container
cat backup.sql | uc exec -T db-service psql -U postgres mydb
# Run a task in the background (detached mode)
uc exec -d web-service /scripts/cleanup.sh`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
serviceName := args[0]
command := args[1:]
if len(command) == 0 {
command = DEFAULT_COMMAND
}
return runExec(cmd.Context(), uncli, serviceName, command, opts)
},
GroupID: groupID,
}
execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background")
execCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", false,
"Disable pseudo-TTY allocation. By default 'uc exec' allocates a TTY when connected to a terminal.")
// Keep "-i" and "-t" flags hidden for compatibility with docker exec
execCmd.Flags().BoolVarP(&opts.interactive, "interactive", "i", true, "Keep STDIN open even if not attached")
execCmd.Flags().MarkHidden("interactive")
execCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY")
execCmd.Flags().MarkHidden("tty")
// Common flags
execCmd.Flags().StringVar(&opts.containerId, "container", "",
"ID of the container to exec into. Accepts full ID or a unique prefix "+
"(default is the random container of the service)")
// This tells Cobra that all flags must come before positional arguments, so that
// commands with their own flags can be handled correctly.
execCmd.Flags().SetInterspersed(false)
return execCmd
}
func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error {
// Disable TTY allocation if not connected to a terminal
if !tui.IsStdoutTerminal() {
opts.noTty = true
}
if !opts.detach {
// Check if we're trying to attach to a TTY from a non-TTY client, e.g.
// when doing an 'cmd | uc exec ...'
stdin := streams.NewIn(os.Stdin)
// TODO: this logic/behavior mirrors docker-compose, but we can be smarter about it and detect TTY dynamically
if err := stdin.CheckTty(opts.interactive, !opts.noTty); err != nil {
return fmt.Errorf("check TTY: %w; use -T option to disable TTY allocation", err)
}
}
client, err := uncli.ConnectClusterWithOptions(ctx, cli.ConnectOptions{
ShowProgress: false,
})
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer client.Close()
execConfig := api.ExecOptions{
Command: command,
AttachStdin: opts.interactive,
Tty: !opts.noTty,
Detach: opts.detach,
}
if !opts.detach {
execConfig.AttachStdout = true
execConfig.AttachStderr = true
}
exitCode, err := client.ExecContainer(ctx, serviceName, opts.containerId, execConfig)
if err != nil {
return fmt.Errorf("exec container: %w", err)
}
// For non-detached mode, exit with the same code as the executed command
if !opts.detach {
if exitCode != 0 {
os.Exit(exitCode)
}
}
return nil
}
+29 -15
View File
@@ -4,21 +4,22 @@ import (
"context"
"fmt"
"os"
"slices"
"text/tabwriter"
"time"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type inspectOptions struct {
service string
cluster string
}
func NewInspectCommand() *cobra.Command {
func NewInspectCommand(groupID string) *cobra.Command {
opts := inspectOptions{}
cmd := &cobra.Command{
Use: "inspect SERVICE",
@@ -29,16 +30,13 @@ func NewInspectCommand() *cobra.Command {
opts.service = args[0]
return inspect(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
cmd.Flags().StringVarP(
&opts.cluster, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
client, err := uncli.ConnectCluster(ctx, opts.cluster)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -58,23 +56,31 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
machinesNamesByID[m.Machine.Id] = m.Machine.Name
}
fmt.Printf("ID: %s\n", svc.ID)
fmt.Printf("Service ID: %s\n", svc.ID)
fmt.Printf("Name: %s\n", svc.Name)
fmt.Printf("Mode: %s\n", svc.Mode)
fmt.Println()
// Parse created times for sorting and display.
createdTimes := make(map[string]time.Time, len(svc.Containers))
for _, ctr := range svc.Containers {
createdTimes[ctr.Container.ID], _ = time.Parse(time.RFC3339Nano, ctr.Container.Created)
}
// Sort containers by created time (newest first).
slices.SortFunc(svc.Containers, func(a, b api.MachineServiceContainer) int {
return createdTimes[b.Container.ID].Compare(createdTimes[a.Container.ID])
})
// Print the list of containers in a table format.
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tMACHINE"); err != nil {
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tIP ADDRESS\tMACHINE"); err != nil {
return fmt.Errorf("write header: %w", err)
}
now := time.Now().UTC()
for _, ctr := range svc.Containers {
createdAt, err := time.Parse(time.RFC3339Nano, ctr.Container.Created)
if err != nil {
return fmt.Errorf("parse created time: %w", err)
}
created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
machine := machinesNamesByID[ctr.MachineID]
if machine == "" {
@@ -85,13 +91,21 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
return fmt.Errorf("get human state: %w", err)
}
ip := ctr.Container.UncloudNetworkIP()
ipStr := ""
// The container might not have an IP if it's not running or uses the host network.
if ip.IsValid() {
ipStr = ip.String()
}
_, err = fmt.Fprintf(
tw,
"%s\t%s\t%s\t%s\t%s\n",
"%s\t%s\t%s\t%s\t%s\t%s\n",
stringid.TruncateID(ctr.Container.ID),
ctr.Container.Config.Image,
created,
state,
ipStr,
machine,
)
if err != nil {
+363
View File
@@ -0,0 +1,363 @@
package service
import (
"context"
"errors"
"fmt"
"image/color"
"os"
"slices"
"strconv"
"strings"
"time"
"charm.land/lipgloss/v2"
mapset "github.com/deckarep/golang-set/v2"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose"
"github.com/spf13/cobra"
)
type logsOptions struct {
files []string
follow bool
tail string
since string
until string
utc bool
machines []string
}
func NewLogsCommand(groupID string) *cobra.Command {
var options logsOptions
cmd := &cobra.Command{
Use: "logs [SERVICE...]",
Aliases: []string{"log"},
Short: "View service logs.",
Long: `View logs from all replicas of the specified service(s) across all machines in the cluster.
If no services are specified, streams logs from all services defined in the Compose file
(compose.yaml by default or the file(s) specified with --file).`,
Example: ` # View recent logs for a service.
uc logs web
# Stream logs in real-time (follow mode).
uc logs -f web
# View logs from multiple services.
uc logs web api db
# View logs from all services in compose.yaml.
uc logs
# Show last 20 lines per replica (default is 100).
uc logs -n 20 web
# Show all logs without line limit.
uc logs -n all web
# View logs from a specific time range.
uc logs --since 3h --until 1h30m web
# View logs only from replicas running on specific machines.
uc logs -m machine1,machine2 web api`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return runLogs(cmd.Context(), uncli, args, options)
},
GroupID: groupID,
}
cmd.Flags().StringSliceVar(&options.files, "file", nil,
"One or more Compose files to load service names from when no services are specified. (default compose.yaml)")
cmd.Flags().BoolVarP(&options.follow, "follow", "f", false,
"Continually stream new logs.")
cmd.Flags().StringSliceVarP(&options.machines, "machine", "m", nil,
"Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.")
cmd.Flags().StringVar(&options.since, "since", "",
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
"Examples:\n"+
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
" --since 1h Relative duration (1 hour ago)\n"+
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
cmd.Flags().StringVarP(&options.tail, "tail", "n", "100",
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.")
cmd.Flags().StringVar(&options.until, "until", "",
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
"See --since for examples.")
cmd.Flags().BoolVar(&options.utc, "utc", false,
"Print timestamps in UTC instead of local timezone.")
return cmd
}
func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logsOptions) error {
// If no services specified, try to load them from the Compose file(s).
fromCompose := false
if len(serviceNames) == 0 {
fromCompose = true
project, err := compose.LoadProject(ctx, opts.files)
if err != nil {
return fmt.Errorf("load Compose file(s): %w", err)
}
uncli.SetClusterContextIfUnset(compose.ClusterContext(project))
// View logs for all services, including disabled by inactive profiles.
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...)
if len(serviceNames) == 0 {
return errors.New("no services found in Compose file(s)")
}
}
// Parse tail option.
tail := -1
if opts.tail != "all" {
tailInt, err := strconv.Atoi(opts.tail)
if err != nil {
return fmt.Errorf("invalid --tail value '%s': %w", opts.tail, err)
}
tail = tailInt
}
c, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer c.Close()
logsOpts := api.ServiceLogsOptions{
Follow: opts.follow,
Tail: tail,
Since: opts.since,
Until: opts.until,
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
}
// Collect log streams from all services. When service names come from a Compose file,
// skip the ones that are not found in the cluster (they may have been removed or not deployed yet).
machineIDsSet := mapset.NewSet[string]()
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames))
var foundServices, notFoundServices []string
for _, serviceName := range serviceNames {
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts)
if err != nil {
if errors.Is(err, api.ErrNotFound) && fromCompose {
notFoundServices = append(notFoundServices, serviceName)
continue
}
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err)
}
svcStreams = append(svcStreams, ch)
foundServices = append(foundServices, serviceName)
machineIDs := svc.MachineIDs()
machineIDsSet.Append(machineIDs...)
}
if fromCompose {
if len(foundServices) == 0 {
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
strings.Join(opts.files, ", "))
}
serviceNames = foundServices
for _, name := range notFoundServices {
tui.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
}
}
var stream <-chan api.ServiceLogEntry
if len(serviceNames) == 1 {
stream = svcStreams[0]
} else {
// Merge all service streams into a single sorted stream without stall detection as its handled per-service.
merger := client.NewLogMerger(svcStreams, client.LogMergerOptions{})
stream = merger.Stream()
}
// Fetch machine names for all machines (machineIDsSet) service containers are running on.
machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: machineIDsSet.ToSlice()})
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machineNames := make([]string, 0, len(machines))
for _, m := range machines {
machineNames = append(machineNames, m.Machine.Name)
}
formatter := newLogFormatter(machineNames, serviceNames, opts.utc)
// Print merged logs.
for entry := range stream {
if entry.Err != nil {
formatter.printError(entry)
continue
}
formatter.printEntry(entry)
}
return nil
}
// Available colors for machine/service differentiation.
var colorPalette = []color.Color{
lipgloss.BrightGreen,
lipgloss.BrightYellow,
lipgloss.BrightBlue,
lipgloss.BrightMagenta,
lipgloss.BrightCyan,
lipgloss.Green,
lipgloss.Yellow,
lipgloss.Blue,
lipgloss.Magenta,
lipgloss.Cyan,
}
// logFormatter handles formatting and printing of log entries with dynamic column alignment.
type logFormatter struct {
machineNames []string
serviceNames []string
maxMachineWidth int
maxServiceWidth int
utc bool
}
func newLogFormatter(machineNames, serviceNames []string, utc bool) *logFormatter {
slices.Sort(machineNames)
slices.Sort(serviceNames)
maxMachineWidth := 0
for _, name := range machineNames {
if len(name) > maxMachineWidth {
maxMachineWidth = len(name)
}
}
maxServiceWidth := 0
for _, name := range serviceNames {
if len(name) > maxServiceWidth {
maxServiceWidth = len(name)
}
}
return &logFormatter{
machineNames: machineNames,
serviceNames: serviceNames,
maxMachineWidth: maxMachineWidth,
maxServiceWidth: maxServiceWidth,
utc: utc,
}
}
// formatTimestamp formats timestamp using local timezone or UTC if configured.
func (f *logFormatter) formatTimestamp(t time.Time) string {
if f.utc {
t = t.UTC()
} else {
t = t.In(time.Local)
}
dimStyle := lipgloss.NewStyle().Faint(true)
return dimStyle.Render(t.Format(time.StampMilli))
}
func (f *logFormatter) formatMachine(name string) string {
style := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxMachineWidth - len(name))
if len(f.serviceNames) == 1 {
// Machine name is coloured for single-service logs.
i := slices.Index(f.machineNames, name)
if i == -1 {
f.machineNames = append(f.machineNames, name)
i = len(f.machineNames) - 1
}
style = style.Foreground(colorPalette[i%len(colorPalette)])
}
return style.Render(name)
}
func (f *logFormatter) formatServiceContainer(serviceName, containerID string) string {
styleService := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxServiceWidth - len(serviceName))
styleContainer := lipgloss.NewStyle().Faint(true)
if len(f.serviceNames) > 1 {
// Service name is coloured for multi-service logs.
i := slices.Index(f.serviceNames, serviceName)
if i == -1 {
f.serviceNames = append(f.serviceNames, serviceName)
i = len(f.serviceNames) - 1
}
styleService = styleService.Foreground(colorPalette[i%len(colorPalette)])
}
return styleService.Render(serviceName) + styleContainer.Render("["+containerID[:5]+"]")
}
// printEntry prints a single log entry with proper formatting.
func (f *logFormatter) printEntry(entry api.ServiceLogEntry) {
if entry.Stream != api.LogStreamStdout && entry.Stream != api.LogStreamStderr {
return
}
var output strings.Builder
// Timestamp
output.WriteString(f.formatTimestamp(entry.Timestamp))
output.WriteString(" ")
// Machine name
output.WriteString(f.formatMachine(entry.Metadata.MachineName))
output.WriteString(" ")
// Service[container_id]
output.WriteString(f.formatServiceContainer(entry.Metadata.ServiceName, entry.Metadata.ContainerID))
output.WriteString(" ")
// Message
output.Write(entry.Message)
// Print to appropriate stream.
if entry.Stream == api.LogStreamStderr {
fmt.Fprint(os.Stderr, output.String())
} else {
fmt.Print(output.String())
}
}
// printError prints an error entry (e.g., stalled stream warning).
func (f *logFormatter) printError(entry api.ServiceLogEntry) {
if entry.Metadata.ContainerID != "" {
msg := fmt.Sprintf("WARNING: log stream from %s[%s] on machine '%s'",
entry.Metadata.ServiceName,
stringid.TruncateID(entry.Metadata.ContainerID),
entry.Metadata.MachineName)
if errors.Is(entry.Err, api.ErrLogStreamStalled) {
msg += " stopped responding"
} else {
msg += fmt.Sprintf(": %v", entry.Err)
}
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow
fmt.Fprintln(os.Stderr, style.Render(msg))
} else {
msg := fmt.Sprintf("ERROR: %v", entry.Err)
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) // Bold bright red
fmt.Fprintln(os.Stderr, style.Render(msg))
}
}
+27 -20
View File
@@ -4,34 +4,31 @@ import (
"context"
"fmt"
"os"
"slices"
"strings"
"text/tabwriter"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
func NewListCommand() *cobra.Command {
// TODO(lhf): rename to context
var cluster string
func NewListCommand(groupID string) *cobra.Command {
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List services.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, cluster)
return list(cmd.Context(), uncli)
},
GroupID: groupID,
}
cmd.Flags().StringVarP(
&cluster, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
client, err := uncli.ConnectCluster(ctx, clusterName)
func list(ctx context.Context, uncli *cli.CLI) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -42,15 +39,15 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
return fmt.Errorf("list services: %w", err)
}
serviceNames := make(map[string]struct{}, len(services))
// Sort services by name.
haveDuplicateNames := false
for _, svc := range services {
if _, exists := serviceNames[svc.Name]; exists {
slices.SortFunc(services, func(a, b api.Service) int {
if a.Name == b.Name {
haveDuplicateNames = true
break
}
serviceNames[svc.Name] = struct{}{}
return strings.Compare(a.ID, b.ID)
}
return strings.Compare(a.Name, b.Name)
})
// Print the list of services in a table format.
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
@@ -61,19 +58,29 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
return fmt.Errorf("write header: %w", err)
}
}
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tENDPOINTS"); err != nil {
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tIMAGE\tENDPOINTS"); err != nil {
return fmt.Errorf("write header: %w", err)
}
for _, s := range services {
endpointsSlice := s.Endpoints()
endpoints := strings.Join(endpointsSlice, ", ")
images := strings.Join(s.Images(), ", ")
endpoints := strings.Join(s.Endpoints(), ", ")
// If no endpoints from ports, check if the service uses custom Caddy config.
if endpoints == "" {
for _, ctr := range s.Containers {
if ctr.Container.ServiceSpec.CaddyConfig() != "" {
endpoints = "(custom Caddy config)"
}
}
}
if haveDuplicateNames {
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
return fmt.Errorf("write row: %w", err)
}
}
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", s.Name, s.Mode, len(s.Containers), endpoints); err != nil {
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n",
s.Name, s.Mode, len(s.Containers), images, endpoints); err != nil {
return fmt.Errorf("write row: %w", err)
}
}
+9 -8
View File
@@ -11,31 +11,32 @@ import (
type rmOptions struct {
services []string
cluster string
}
func NewRmCommand() *cobra.Command {
func NewRmCommand(groupID string) *cobra.Command {
opts := rmOptions{}
cmd := &cobra.Command{
Use: "rm SERVICE [SERVICE...]",
Aliases: []string{"remove", "delete"},
Short: "Remove one or more services.",
Long: `Remove one or more services.
The volumes used by the services are preserved and should be removed separately
with 'uc volume rm'. Anonymous Docker volumes (automatically created from VOLUME
directives in image Dockerfiles) are automatically removed with their containers.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.services = args
return rm(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
cmd.Flags().StringVarP(
&opts.cluster, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
client, err := uncli.ConnectCluster(ctx, opts.cluster)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -50,5 +51,5 @@ func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
}, uncli.ProgressOut(), "Removing service "+s)
}
return nil
return err
}
+10 -6
View File
@@ -8,14 +8,18 @@ func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "service",
Aliases: []string{"svc"},
Short: "Manage services in an Uncloud cluster.",
Short: "Manage services in the cluster.",
}
cmd.AddCommand(
NewInspectCommand(),
NewListCommand(),
NewRmCommand(),
NewRunCommand(),
NewScaleCommand(),
NewExecCommand(""),
NewInspectCommand(""),
NewListCommand(""),
NewLogsCommand(""),
NewRmCommand(""),
NewRunCommand(""),
NewScaleCommand(""),
NewStartCommand(""),
NewStopCommand(""),
)
return cmd
}
+48 -16
View File
@@ -9,6 +9,7 @@ import (
dockeropts "github.com/docker/cli/opts"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/daemon/names"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
@@ -27,18 +28,18 @@ type runOptions struct {
machines []string
memory dockeropts.MemBytes
mode string
shmSize dockeropts.MemBytes
name string
privileged bool
publish []string
pull string
replicas uint
ulimits []string
user string
volumes []string
cluster string
}
func NewRunCommand() *cobra.Command {
func NewRunCommand(groupID string) *cobra.Command {
opts := runOptions{}
cmd := &cobra.Command{
@@ -56,6 +57,7 @@ func NewRunCommand() *cobra.Command {
return run(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
@@ -77,8 +79,12 @@ func NewRunCommand() *cobra.Command {
"Placement constraint by machine names, limiting which machines the service can run on. Can be specified "+
"multiple times or as a comma-separated list of machine names. (default is any suitable machine)")
cmd.Flags().VarP(&opts.memory, "memory", "",
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix "+
"(b, k, m, g). Default unit is bytes if no suffix specified.\n"+
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix (b, k, m, g).\n"+
"Default unit is bytes if no suffix specified.\n"+
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
cmd.Flags().VarP(&opts.shmSize, "shm-size", "",
"Maximum amount of shared memory (mounted at /dev/shm) a service container can use. Value is a positive integer\n"+
"with optional unit suffix (b, k, m, g). Default unit is bytes if no suffix specified.\n"+
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
"Assign a name to the service. A random name is generated if not specified.")
@@ -103,6 +109,12 @@ func NewRunCommand() *cobra.Command {
cmd.Flags().StringVarP(&opts.user, "user", "u", "",
"User name or UID and optionally group name or GID used for running the command inside service containers.\n"+
"Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.")
cmd.Flags().StringSliceVar(&opts.ulimits, "ulimit", nil,
"Set resource limits for service containers. Can be specified multiple times.\n"+
"Format: type=soft_limit[:hard_limit]. If hard limit is not specified, soft limit is used for both.\n"+
"Examples:\n"+
" --ulimit nofile=1024:2048 Set soft limit to 1024 and hard limit to 2048 for number of open files\n"+
" --ulimit nproc=65535 Set both soft and hard limits to 65535 for number of processes")
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
"Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+
"the volume is located. Can be specified multiple times.\n"+
@@ -112,11 +124,6 @@ func NewRunCommand() *cobra.Command {
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
cmd.Flags().StringVarP(
&opts.cluster, "context", "c", "",
"Name of the cluster context to run the service in. (default is the current context)",
)
return cmd
}
@@ -126,7 +133,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
return err
}
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -200,13 +207,18 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
ports[i] = port
}
volumes, mounts, err := parseVolumeFlags(opts.volumes)
placement := api.Placement{
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
}
ulimits, err := parseUlimits(opts.ulimits)
if err != nil {
return spec, err
}
placement := api.Placement{
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
volumes, mounts, err := parseVolumeFlags(opts.volumes)
if err != nil {
return spec, err
}
spec = api.ServiceSpec{
@@ -219,6 +231,8 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
Resources: api.ContainerResources{
CPU: opts.cpu.Value(),
Memory: opts.memory.Value(),
SharedMemory: opts.shmSize.Value(),
Ulimits: ulimits,
},
User: opts.user,
VolumeMounts: mounts,
@@ -308,6 +322,24 @@ func parseVolumeFlags(volumes []string) ([]api.VolumeSpec, []api.VolumeMount, er
return specs, mounts, nil
}
// parseUlimits parses ulimit flag values in Docker CLI format (type=soft[:hard]).
func parseUlimits(ulimits []string) (map[string]api.Ulimit, error) {
if len(ulimits) == 0 {
return nil, nil
}
result := make(map[string]api.Ulimit, len(ulimits))
for _, u := range ulimits {
parsed, err := units.ParseUlimit(u)
if err != nil {
return nil, fmt.Errorf("invalid ulimit '%s': %w", u, err)
}
result[parsed.Name] = api.Ulimit{Soft: parsed.Soft, Hard: parsed.Hard}
}
return result, nil
}
func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error) {
var spec api.VolumeSpec
var mount api.VolumeMount
@@ -326,8 +358,8 @@ func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error
volumeNoCopy := false
if len(parts) == 3 {
opts := strings.Split(parts[2], ",")
for _, opt := range opts {
opts := strings.SplitSeq(parts[2], ",")
for opt := range opts {
switch opt {
case "ro", "readonly":
mount.ReadOnly = true
+59 -43
View File
@@ -2,12 +2,16 @@ package service
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
"github.com/charmbracelet/huh"
"charm.land/lipgloss/v2"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
@@ -15,17 +19,19 @@ import (
type scaleOptions struct {
service string
replicas uint
cluster string
yes bool
}
func NewScaleCommand() *cobra.Command {
func NewScaleCommand(groupID string) *cobra.Command {
opts := scaleOptions{}
cmd := &cobra.Command{
Use: "scale SERVICE REPLICAS",
Short: "Scale a replicated service by changing the number of replicas.",
Long: "Scale a replicated service by changing the number of replicas. Scaling down requires confirmation.",
Long: "Scale a replicated service by changing the number of replicas.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.service = args[0]
@@ -37,12 +43,12 @@ func NewScaleCommand() *cobra.Command {
return scale(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
cmd.Flags().StringVarP(
&opts.cluster, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
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
}
@@ -57,7 +63,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
)
}
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -76,7 +82,8 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
currentReplicas := uint(len(svc.Containers))
if currentReplicas == opts.replicas {
fmt.Printf("Service '%s' already has %d replicas. No changes required.\n", svc.Name, currentReplicas)
fmt.Printf("Service %s already has %s replicas. No changes required.\n",
tui.NameStyle.Render(svc.Name), tui.Bold.Render(fmt.Sprintf("%d", currentReplicas)))
return nil
}
@@ -91,23 +98,51 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
}
if len(plan.Operations) == 0 {
fmt.Printf("Service '%s' is already scaled to %d replicas.\n", svc.Name, opts.replicas)
fmt.Printf("Service %s is already scaled to %d replicas.\n", tui.NameStyle.Render(svc.Name), opts.replicas)
return nil
}
if opts.replicas < currentReplicas {
// Initialise a machine and container name resolver to properly format the plan output.
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
if err != nil {
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
}
fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas)
fmt.Println(plan.Format(resolver))
fmt.Println(tui.Bold.Underline(true).Render("Scaling plan"))
fmt.Println()
// Ask for confirmation before scaling down as it may cause data loss.
confirmed, err := confirm()
directConn := uncli.DirectConnection()
contextName := uncli.ContextOverrideOrCurrent()
deployTarget := ""
if directConn != "" {
deployTarget = directConn
fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn))
fmt.Println()
} else if contextName != "" && len(uncli.Config.Contexts) > 1 {
// Only show context if there's more than one to avoid unnecessary clutter.
deployTarget = contextName
fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName))
fmt.Println()
}
fmt.Println(plan.Format())
summary := plan.FormatSummary()
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
fmt.Println(summary)
fmt.Println()
// Ask for confirmation unless auto-confirmed with --yes.
if !opts.yes {
if !tui.IsStdinTerminal() {
return errors.New("cannot ask to confirm scaling plan in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
}
title := "Proceed with scaling?"
// Include the direct connection or context name in the confirmation prompt to avoid accidentally
// scaling on the wrong cluster.
if deployTarget != "" {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title
title = "Proceed with scaling on " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?")
}
confirmed, err := tui.Confirm(title)
if err != nil {
return fmt.Errorf("confirm scaling: %w", err)
}
@@ -117,7 +152,8 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
}
}
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas)
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)",
tui.NameStyle.Render(svc.Name), currentReplicas, opts.replicas)
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if _, err = deployment.Run(ctx); err != nil {
return fmt.Errorf("deploy service: %w", err)
@@ -130,23 +166,3 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
return nil
}
func confirm() (bool, error) {
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(
"Do you want to continue?",
).
Affirmative("Yes!").
Negative("No").
Value(&confirmed),
),
).WithAccessible(true)
if err := form.Run(); err != nil {
return false, err
}
return confirmed, nil
}
+54
View File
@@ -0,0 +1,54 @@
//nolint:dupl
package service
import (
"context"
"fmt"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/spf13/cobra"
)
type startOptions struct {
services []string
}
func NewStartCommand(groupID string) *cobra.Command {
opts := startOptions{}
cmd := &cobra.Command{
Use: "start SERVICE [SERVICE...]",
Short: "Start one or more services.",
Long: `Start one or more previously stopped services.
Starts all containers of the specified service(s) across all machines in the cluster.
Services can be specified by name or ID.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.services = args
return start(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
return cmd
}
func start(ctx context.Context, uncli *cli.CLI, opts startOptions) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer client.Close()
for _, s := range opts.services {
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err = client.StartService(ctx, s); err != nil {
return fmt.Errorf("start service '%s': %w", s, err)
}
return nil
}, uncli.ProgressOut(), "Starting service "+s)
}
return err
}
+68
View File
@@ -0,0 +1,68 @@
//nolint:dupl
package service
import (
"context"
"fmt"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container"
"github.com/psviderski/uncloud/internal/cli"
"github.com/spf13/cobra"
)
type stopOptions struct {
services []string
signal string
timeout int
}
func NewStopCommand(groupID string) *cobra.Command {
opts := stopOptions{}
cmd := &cobra.Command{
Use: "stop SERVICE [SERVICE...]",
Short: "Stop one or more services.",
Long: `Stop one or more running services.
Gracefully stops all containers of the specified service(s) across all machines in the cluster.
Services can be specified by name or ID. Stopped services can be restarted with 'uc start'.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.services = args
return stop(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
cmd.Flags().StringVarP(&opts.signal, "signal", "s", "",
"Signal to send to each container's main process.\n"+
"Can be a signal name (SIGTERM, SIGINT, SIGHUP, etc.) or a number. (default SIGTERM)")
cmd.Flags().IntVarP(&opts.timeout, "timeout", "t", 10,
"Seconds to wait for each container to stop gracefully before forcibly killing it with SIGKILL.\n"+
"Use -1 to wait indefinitely.")
return cmd
}
func stop(ctx context.Context, uncli *cli.CLI, opts stopOptions) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer client.Close()
stopOpts := container.StopOptions{
Signal: opts.signal,
Timeout: &opts.timeout,
}
for _, s := range opts.services {
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err = client.StopService(ctx, s, stopOpts); err != nil {
return fmt.Errorf("stop service '%s': %w", s, err)
}
return nil
}, uncli.ProgressOut(), "Stopping service "+s)
}
return err
}
+2 -5
View File
@@ -6,7 +6,7 @@ import (
"slices"
"strings"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/machine/api/pb"
@@ -19,7 +19,6 @@ type createOptions struct {
driverOpts []string
labels []string
machine string
context string
}
func NewCreateCommand() *cobra.Command {
@@ -50,14 +49,12 @@ func NewCreateCommand() *cobra.Command {
"Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.")
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to create the volume on.")
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)")
return cmd
}
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
client, err := uncli.ConnectCluster(ctx, opts.context)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+1 -4
View File
@@ -13,7 +13,6 @@ import (
type inspectOptions struct {
machine string
context string
}
func NewInspectCommand() *cobra.Command {
@@ -32,14 +31,12 @@ func NewInspectCommand() *cobra.Command {
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine where the volume is located. "+
"If not specified, the volume will be searched across all machines.")
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)")
return cmd
}
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
client, err := uncli.ConnectCluster(ctx, opts.context)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+1 -5
View File
@@ -16,7 +16,6 @@ import (
type listOptions struct {
machines []string
quiet bool
context string
}
func NewListCommand() *cobra.Command {
@@ -38,14 +37,11 @@ func NewListCommand() *cobra.Command {
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
"Only display volume names.")
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)")
return cmd
}
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
client, err := uncli.ConnectCluster(ctx, opts.context)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+3 -6
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
@@ -14,7 +15,6 @@ type removeOptions struct {
force bool
machines []string
yes bool
context string
}
func NewRemoveCommand() *cobra.Command {
@@ -41,14 +41,11 @@ func NewRemoveCommand() *cobra.Command {
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Do not prompt for confirmation before removing the volume(s).")
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)")
return cmd
}
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
client, err := uncli.ConnectCluster(ctx, opts.context)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -83,7 +80,7 @@ func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOpti
}
fmt.Println()
confirmed, err := cli.Confirm()
confirmed, err := tui.Confirm("")
if err != nil {
return fmt.Errorf("confirm removal: %w", err)
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "volume",
Short: "Manage volumes in an Uncloud cluster.",
Short: "Manage volumes in the cluster.",
}
cmd.AddCommand(
NewCreateCommand(),
+132
View File
@@ -0,0 +1,132 @@
package wg
import (
"context"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/spf13/cobra"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "wg",
Short: "Inspect WireGuard network",
}
cmd.AddCommand(newShowCommand())
return cmd
}
type showOptions struct {
machine string
}
func newShowCommand() *cobra.Command {
opts := showOptions{}
cmd := &cobra.Command{
Use: "show",
Short: "Show WireGuard network configuration for a machine.",
Long: "Show the WireGuard network configuration for the machine currently connected to " +
"(or specified by the global --connect flag).",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return runShow(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
"Name or ID of the machine to show the configuration for. (default is connected machine)")
return cmd
}
func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connection failed: %w", err)
}
defer client.Close()
if opts.machine != "" {
// Proxy requests to the specified machine.
ctx, _, err = client.ProxyMachinesContext(ctx, []string{opts.machine})
if err != nil {
return err
}
}
resp, err := client.MachineClient.InspectWireGuardNetwork(ctx, nil)
if err != nil {
if status.Code(err) == codes.Unimplemented {
return fmt.Errorf("inspect WireGuard network: "+
"make sure the target machine is running uncloudd daemon version >= 0.16.0: %w", err)
}
return err
}
machines, err := client.ListMachines(ctx, nil)
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machinesNamesByPublicKey := make(map[string]string)
for _, m := range machines {
publicKey := wgtypes.Key(m.Machine.Network.PublicKey).String()
machinesNamesByPublicKey[publicKey] = m.Machine.Name
}
// Fetch the machine's name for more descriptive output
inspectResp, err := client.MachineClient.InspectMachine(ctx, nil)
if err == nil {
fmt.Printf("Machine name: %s\n", inspectResp.Machines[0].Machine.Name)
}
fmt.Printf("WireGuard interface: %s\n", resp.InterfaceName)
fmt.Printf("WireGuard public key: %s\n", wgtypes.Key(resp.PublicKey).String())
fmt.Printf("WireGuard port: %d\n", resp.ListenPort)
fmt.Println()
if len(resp.Peers) == 0 {
fmt.Println("No WireGuard peers configured.")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
if _, err = fmt.Fprintln(tw, "PEER\tPUBLIC KEY\tENDPOINT\tHANDSHAKE\tRECEIVED\tSENT\tALLOWED IPS"); err != nil {
return fmt.Errorf("write header: %w", err)
}
for _, peer := range resp.Peers {
machineName, ok := machinesNamesByPublicKey[wgtypes.Key(peer.PublicKey).String()]
if !ok {
machineName = "(unknown)"
}
lastHandshake := ""
if peer.LastHandshakeTime != nil {
lastHandshake = time.Since(peer.LastHandshakeTime.AsTime()).Round(time.Second).String() + " ago"
}
_, err = fmt.Fprintf(
tw,
"%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
machineName,
wgtypes.Key(peer.PublicKey).String(),
peer.Endpoint,
lastHandshake,
units.HumanSize(float64(peer.ReceiveBytes)),
units.HumanSize(float64(peer.TransmitBytes)),
strings.Join(peer.AllowedIps, ", "),
)
if err != nil {
return fmt.Errorf("write row: %w", err)
}
}
return tw.Flush()
}
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"context"
"fmt"
"io"
"net"
"os"
"github.com/psviderski/uncloud/internal/machine"
"github.com/spf13/cobra"
)
func newDialStdioCommand() *cobra.Command {
var socketPath string
cmd := &cobra.Command{
Use: "dial-stdio",
Short: "Proxy stdin/stdout to the Uncloud API socket",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDialStdio(cmd.Context(), socketPath, os.Stdin, os.Stdout)
},
}
cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultUncloudSockPath,
"Path to the Uncloud API socket")
return cmd
}
// halfReadCloser is the read side of a half-duplex connection.
type halfReadCloser interface {
io.Reader
CloseRead() error
}
// halfWriteCloser is the write side of a half-duplex connection.
type halfWriteCloser interface {
io.Writer
CloseWrite() error
}
// halfReadCloserWrapper wraps an io.ReadCloser to implement halfReadCloser.
type halfReadCloserWrapper struct {
io.ReadCloser
}
func (x *halfReadCloserWrapper) CloseRead() error {
return x.Close()
}
// halfWriteCloserWrapper wraps an io.WriteCloser to implement halfWriteCloser.
type halfWriteCloserWrapper struct {
io.WriteCloser
}
func (x *halfWriteCloserWrapper) CloseWrite() error {
return x.Close()
}
func runDialStdio(ctx context.Context, socketPath string, stdin io.Reader, stdout io.Writer) error {
// Connect to the unix socket.
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "unix", socketPath)
if err != nil {
return fmt.Errorf("connect to socket %q: %w", socketPath, err)
}
defer conn.Close()
// Wrap stdin/stdout to support half-closing.
var stdinCloser halfReadCloser
if c, ok := stdin.(halfReadCloser); ok {
stdinCloser = c
} else if c, ok := stdin.(io.ReadCloser); ok {
stdinCloser = &halfReadCloserWrapper{c}
}
var stdoutCloser halfWriteCloser
if c, ok := stdout.(halfWriteCloser); ok {
stdoutCloser = c
} else if c, ok := stdout.(io.WriteCloser); ok {
stdoutCloser = &halfWriteCloserWrapper{c}
}
// Copy data bidirectionally between stdin/stdout and the socket.
stdin2socket := make(chan error, 1)
socket2stdout := make(chan error, 1)
// Copy from stdin to socket.
go func() {
_, err := io.Copy(conn, stdin)
stdin2socket <- err
// Close write side of connection after stdin is done.
if unixConn, ok := conn.(*net.UnixConn); ok {
unixConn.CloseWrite()
}
if stdinCloser != nil {
stdinCloser.CloseRead()
}
}()
// Copy from socket to stdout.
go func() {
_, err := io.Copy(stdout, conn)
socket2stdout <- err
// Close read side of connection after socket is done sending.
if unixConn, ok := conn.(*net.UnixConn); ok {
unixConn.CloseRead()
}
if stdoutCloser != nil {
stdoutCloser.CloseWrite()
}
}()
select {
case err = <-stdin2socket:
if err != nil {
return err
}
// wait for stdout
err = <-socket2stdout
case err = <-socket2stdout:
// return immediately, matching Docker's approach
// (stdin is never closed when TTY)
}
return err
}
+3
View File
@@ -42,6 +42,9 @@ func main() {
"Directory for storing persistent machine state")
_ = cmd.MarkFlagDirname("data-dir")
// Add dial-stdio subcommand.
cmd.AddCommand(newDialStdioCommand())
// ctx is canceled when the daemon command is interrupted.
ctx, cancel := context.WithCancel(context.Background())
+12 -12
View File
@@ -17,57 +17,57 @@ func newIPFSLogger(l *slog.Logger) *ipfsLogger {
return &ipfsLogger{log: *l}
}
func (l *ipfsLogger) Debug(args ...interface{}) {
func (l *ipfsLogger) Debug(args ...any) {
l.log.Debug(fmt.Sprint(args...))
}
func (l *ipfsLogger) Debugf(format string, args ...interface{}) {
func (l *ipfsLogger) Debugf(format string, args ...any) {
l.log.Debug(fmt.Sprintf(format, args...))
}
func (l *ipfsLogger) Error(args ...interface{}) {
func (l *ipfsLogger) Error(args ...any) {
l.log.Error(fmt.Sprint(args...))
}
func (l *ipfsLogger) Errorf(format string, args ...interface{}) {
func (l *ipfsLogger) Errorf(format string, args ...any) {
l.log.Error(fmt.Sprintf(format, args...))
}
func (l *ipfsLogger) Fatal(args ...interface{}) {
func (l *ipfsLogger) Fatal(args ...any) {
l.log.Error(fmt.Sprint(args...))
os.Exit(1)
}
func (l *ipfsLogger) Fatalf(format string, args ...interface{}) {
func (l *ipfsLogger) Fatalf(format string, args ...any) {
l.log.Error(fmt.Sprintf(format, args...))
os.Exit(1)
}
func (l *ipfsLogger) Info(args ...interface{}) {
func (l *ipfsLogger) Info(args ...any) {
l.log.Info(fmt.Sprint(args...))
}
func (l *ipfsLogger) Infof(format string, args ...interface{}) {
func (l *ipfsLogger) Infof(format string, args ...any) {
l.log.Info(fmt.Sprintf(format, args...))
}
func (l *ipfsLogger) Panic(args ...interface{}) {
func (l *ipfsLogger) Panic(args ...any) {
msg := fmt.Sprint(args...)
l.log.Error(msg)
panic(msg)
}
func (l *ipfsLogger) Panicf(format string, args ...interface{}) {
func (l *ipfsLogger) Panicf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
l.log.Error(msg)
panic(msg)
}
func (l *ipfsLogger) Warn(args ...interface{}) {
func (l *ipfsLogger) Warn(args ...any) {
l.log.Warn(fmt.Sprint(args...))
}
func (l *ipfsLogger) Warnf(format string, args ...interface{}) {
func (l *ipfsLogger) Warnf(format string, args ...any) {
l.log.Warn(fmt.Sprintf(format, args...))
}
+191 -97
View File
@@ -1,25 +1,30 @@
module github.com/psviderski/uncloud
go 1.23.0
go 1.26
require (
github.com/BurntSushi/toml v1.4.0
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/huh/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.1
github.com/BurntSushi/toml v1.5.0
github.com/Masterminds/semver v1.5.0
github.com/Masterminds/squirrel v1.5.4
github.com/alecthomas/chroma/v2 v2.20.0
github.com/caddyserver/caddy/v2 v2.8.4
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/lipgloss v0.13.0
github.com/compose-spec/compose-go/v2 v2.4.5
github.com/charmbracelet/colorprofile v0.4.2
github.com/compose-spec/compose-go/v2 v2.9.0
github.com/containerd/errdefs v1.0.0
github.com/containerd/platforms v1.0.0-rc.1
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/deckarep/golang-set/v2 v2.8.0
github.com/dgraph-io/badger/v3 v3.2103.5
github.com/distribution/reference v0.6.0
github.com/docker/cli v27.5.0+incompatible
github.com/docker/compose/v2 v2.31.0
github.com/docker/docker v27.4.0-rc.2+incompatible
github.com/docker/go-connections v0.5.0
github.com/docker/cli v28.5.0+incompatible
github.com/docker/compose/v2 v2.40.0
github.com/docker/docker v28.5.0+incompatible
github.com/docker/go-connections v0.6.0
github.com/docker/go-units v0.5.0
github.com/goccy/go-yaml v1.17.1
github.com/google/go-cmp v0.7.0
@@ -37,116 +42,157 @@ require (
github.com/lmittmann/tint v1.0.5
github.com/miekg/dns v1.1.65
github.com/mitchellh/mapstructure v1.5.0
github.com/moby/term v0.5.0
github.com/moby/term v0.5.2
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.0
github.com/opencontainers/image-spec v1.1.1
github.com/psviderski/unregistry v0.4.1
github.com/siderolabs/discovery-api v0.1.4
github.com/siderolabs/discovery-client v0.1.9
github.com/siderolabs/grpc-proxy v0.5.1
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
github.com/vishvananda/netlink v1.3.0
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
github.com/vishvananda/netlink v1.3.1
go.uber.org/zap v1.27.0
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.33.0
golang.org/x/net v0.35.0
golang.org/x/sync v0.11.0
golang.org/x/sys v0.31.0
golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.42.0
golang.org/x/term v0.34.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
google.golang.org/grpc v1.68.1
google.golang.org/protobuf v1.36.3
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a
google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.9
modernc.org/sqlite v1.36.3
tags.cncf.io/container-device-interface v1.0.1
)
require (
cloud.google.com/go/kms v1.19.0 // indirect
cloud.google.com/go/longrunning v0.6.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/Microsoft/hcsshim v0.13.0 // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.22.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/smithy-go v1.20.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bgentry/speakeasy v0.2.0 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/caddyserver/certmagic v0.21.4 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbles v0.20.0 // indirect
github.com/charmbracelet/bubbletea v1.1.1 // indirect
github.com/charmbracelet/x/ansi v0.3.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
github.com/charmbracelet/x/term v0.2.0 // 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/cloudflare/cfssl v1.6.4 // indirect
github.com/containerd/console v1.0.4 // indirect
github.com/containerd/containerd v1.7.24 // indirect
github.com/containerd/containerd/api v1.7.19 // indirect
github.com/containerd/continuity v0.4.4 // indirect
github.com/containerd/errdefs v0.3.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/cgroups/v3 v3.0.5 // indirect
github.com/containerd/console v1.0.5 // indirect
github.com/containerd/containerd/api v1.9.0 // indirect
github.com/containerd/containerd/v2 v2.1.4 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/fifo v1.1.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/containerd/plugin v1.0.0 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/containerd/ttrpc v1.2.5 // indirect
github.com/containerd/typeurl/v2 v2.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/containerd/ttrpc v1.2.7 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/dgraph-io/badger v1.6.2 // indirect
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/distribution/v3 v3.0.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/buildx v0.18.0 // indirect
github.com/docker/buildx v0.29.1 // indirect
github.com/docker/cli-docs-tool v0.10.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.17.0 // indirect
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsevents v0.2.0 // indirect
github.com/fvbommel/sortorder v1.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/go-kit/kit v0.13.0 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/glog v1.2.5 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/cel-go v0.20.1 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/handlers v1.5.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-msgpack v0.5.5 // indirect
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -154,13 +200,15 @@ require (
github.com/hashicorp/go-syslog v1.0.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/arc/v2 v2.0.7 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/logutils v1.0.0 // indirect
github.com/hashicorp/mdns v1.0.5 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/in-toto/in-toto-golang v0.5.0 // indirect
github.com/in-toto/in-toto-golang v0.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/go-block-format v0.2.0 // indirect
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
@@ -178,8 +226,12 @@ require (
github.com/jackc/pgtype v1.14.0 // indirect
github.com/jackc/pgx/v4 v4.18.3 // indirect
github.com/jbenet/goprocess v0.1.4 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@@ -188,12 +240,12 @@ require (
github.com/libp2p/go-libp2p v0.35.4 // indirect
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
github.com/libp2p/go-msgio v0.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
@@ -208,19 +260,25 @@ require (
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/buildkit v0.17.2 // indirect
github.com/moby/buildkit v0.25.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/capability v0.4.0 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/sys/symlink v0.3.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/multiformats/go-base32 v0.1.0 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect
github.com/multiformats/go-multiaddr v0.13.0 // indirect
@@ -231,94 +289,130 @@ require (
github.com/multiformats/go-multistream v0.5.0 // indirect
github.com/multiformats/go-varint v0.0.7 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
github.com/opencontainers/runtime-spec v1.2.1 // indirect
github.com/opencontainers/selinux v1.12.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pires/go-proxyproto v0.7.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/polydawn/refmt v0.89.0 // indirect
github.com/posener/complete v1.2.3 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.61.0 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.48.2 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
github.com/redis/go-redis/v9 v9.7.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.6.0 // indirect
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/siderolabs/gen v0.4.8 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/slackhq/nebula v1.6.1 // indirect
github.com/smallstep/certificates v0.26.1 // indirect
github.com/smallstep/nosql v0.6.1 // indirect
github.com/smallstep/pkcs7 v0.0.0-20231024181729-3b98ecc1ca81 // indirect
github.com/smallstep/pkcs7 v0.1.1 // indirect
github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
github.com/smallstep/truststore v0.13.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c // indirect
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect
github.com/tonistiigi/fsutil v0.0.0-20250605211040-586307ad452f // indirect
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
github.com/urfave/cli v1.22.16 // indirect
github.com/vbatts/tar-split v0.11.6 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/urfave/cli v1.22.17 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zclconf/go-cty v1.17.0 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.etcd.io/bbolt v1.3.11 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
go.opentelemetry.io/otel/log v0.8.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.step.sm/cli-utils v0.9.0 // indirect
go.step.sm/crypto v0.45.0 // indirect
go.step.sm/linkedca v0.20.1 // indirect
go.uber.org/automaxprocs v1.5.3 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap/exp v0.2.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.30.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
howett.net/plist v1.0.0 // indirect
k8s.io/api v0.32.3 // indirect
k8s.io/apimachinery v0.32.3 // indirect
k8s.io/client-go v0.32.3 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
lukechampine.com/blake3 v1.3.0 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // 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/yaml v1.4.0 // indirect
)
+469 -279
View File
File diff suppressed because it is too large Load Diff
+176 -153
View File
@@ -2,175 +2,198 @@ package cli
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"charm.land/lipgloss/v2"
composetypes "github.com/compose-spec/compose-go/v2/types"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/docker/registry"
"github.com/moby/term"
mapset "github.com/deckarep/golang-set/v2"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/flags"
composeapi "github.com/docker/compose/v2/pkg/api"
composev2 "github.com/docker/compose/v2/pkg/compose"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose"
)
type BuildOptions struct {
Files []string
Profiles []string
Services []string
Push bool
// BuildServicesOptions contains options for building services in a Compose project.
type BuildServicesOptions struct {
// BuildArgs sets build-time variables for services. Used in Dockerfiles that declare variables with ARG.
BuildArgs []string
// Check the build configuration for services without building them.
Check bool
// Deps enables to also build services declared as dependencies of the selected Services.
Deps bool
// NoCache disables the use of cache when building images.
NoCache bool
// Pull attempts to pull newer versions of the base images before building.
Pull bool
// Services specifies which services to build. If empty, all services with a build config are built.
Services []string
// Push targets are mutually exclusive.
// PushCluster uploads the built images to cluster machines after building.
PushCluster bool
// PushRegistry uploads the built images to external registries after building.
PushRegistry bool
// Cluster-specific options (only used if PushCluster is true).
// Machines is a list of machine names or IDs to push the image to. If empty, images are pushed to all machines.
Machines []string
}
// GetServicesThatNeedBuild returns a map of services that require building
func GetServicesThatNeedBuild(project *composetypes.Project) map[string]composetypes.ServiceConfig {
servicesToBuild := make(map[string]composetypes.ServiceConfig, len(project.Services))
for serviceName, service := range project.Services {
if service.Build == nil {
// BuildServices builds images for services in the Compose project.
func (cli *CLI) BuildServices(ctx context.Context, project *composetypes.Project, opts BuildServicesOptions) error {
// Validate push targets.
if opts.PushCluster && opts.PushRegistry {
return fmt.Errorf("cannot specify both PushCluster and PushRegistry: choose one push target")
}
// Build service images using Compose implementation.
dockerCli, err := command.NewDockerCli()
if err != nil {
return fmt.Errorf("create docker client: %w", err)
}
// Initialise the Docker CLI with default options.
if err = dockerCli.Initialize(flags.NewClientOptions()); err != nil {
return fmt.Errorf("initialise docker client: %w", err)
}
composeService := composev2.NewComposeService(dockerCli)
buildOpts := composeapi.BuildOptions{
Args: composetypes.NewMappingWithEquals(opts.BuildArgs),
Check: opts.Check,
Deps: opts.Deps,
NoCache: opts.NoCache,
Pull: opts.Pull,
Push: opts.PushRegistry,
Services: opts.Services,
}
if err = composeService.Build(ctx, project, buildOpts); err != nil {
return err
}
if !opts.PushCluster {
return nil
}
// Push built service images to cluster machines.
builtServices, err := ServicesThatNeedBuild(project, opts.Services, opts.Deps)
if err != nil {
return fmt.Errorf("determine built services: %w", err)
}
if len(builtServices) == 0 {
// No services were built, nothing to push.
return nil
}
// Add a line break after the build output.
fmt.Fprintln(cli.ProgressOut())
clusterClient, err := cli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
// Push one service image at a time.
var errs []error
for _, s := range builtServices {
if s.Image == "" {
// Skip services without an image name (shouldn't happen for services with build config).
continue
}
servicesToBuild[serviceName] = service
}
return servicesToBuild
}
// BuildServices builds the services defined in the provided map.
func BuildServices(ctx context.Context, servicesToBuild map[string]composetypes.ServiceConfig, opts BuildOptions) error {
fmt.Println("Building services...")
// Push to the specified machines falling back to service x-machines.
// If none specified, push to *all* cluster machines.
var pushOpts client.PushImageOptions
// Init docker client (can be local or remote, depending on DOCKER_HOST environment variable)
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
if err != nil {
return err
}
defer dockerCli.Close()
serviceImages := make(map[string]string, len(servicesToBuild))
// Build the services using the local docker client and compose libraries
for _, service := range servicesToBuild {
fmt.Printf("Building service: %s\n", service.Name)
imageName, err := buildSingleService(ctx, dockerCli, service, opts)
if err != nil {
return fmt.Errorf("build service %s: %w", service.Name, err)
}
serviceImages[service.Name] = imageName
}
fmt.Printf("Service images are built.\n")
if opts.Push {
err = pushServiceImages(ctx, dockerCli, serviceImages)
if len(opts.Machines) > 0 {
pushOpts.Machines = opts.Machines
} else if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
pushOpts.Machines = machines
}
return err
}
// buildSingleService builds a single service using the Docker client and Compose libraries.
func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, service composetypes.ServiceConfig, opts BuildOptions) (string, error) {
if service.Build == nil {
return "", fmt.Errorf("service %s has no build configuration", service.Name)
}
if service.Image == "" {
return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet", service.Name)
if len(pushOpts.Machines) == 0 {
pushOpts.AllMachines = true
}
buildContextPath := service.Build.Context
imageName := service.Image
// Create a tar archive of the build context
buildContext, err := archive.TarWithOptions(buildContextPath, &archive.TarOptions{})
if err != nil {
return "", fmt.Errorf("failed to create build context for service %s: %w", service.Name, err)
}
buildOptions := types.ImageBuildOptions{
// TODO: Support Dockerfiles outside the build context
// See https://github.com/docker/compose/blob/cf89fd1aa1328d5af77658ccc5a1e1b29981ae80/pkg/compose/build_classic.go#L92
Dockerfile: service.Build.Dockerfile,
Tags: []string{imageName},
Remove: true, // Remove intermediate containers
NoCache: opts.NoCache,
}
buildResponse, err := dockerCli.ImageBuild(ctx, buildContext, buildOptions)
if err != nil {
return "", fmt.Errorf("failed to build image for service %s: %w", service.Name, err)
}
defer buildResponse.Body.Close()
// Display the build response
fd, isTerminal := term.GetFdInfo(os.Stdout)
if err := jsonmessage.DisplayJSONMessagesStream(buildResponse.Body, os.Stdout, fd, isTerminal, nil); err != nil {
return "", fmt.Errorf("failed to display build response for service %s: %w", service.Name, err)
}
return imageName, nil
}
// pushSingleServiceImage pushes a single service image.
func pushSingleServiceImage(ctx context.Context, dockerCli *dockerclient.Client, serviceName string, imageName string) error {
ref, err := reference.ParseNormalizedNamed(imageName)
if err != nil {
return err
}
repoInfo, err := registry.ParseRepositoryInfo(ref)
if err != nil {
return err
}
registryKey := repoInfo.Index.Name
if repoInfo.Index.Official {
registryKey = registry.IndexServer
}
// Load the Docker config file with auth details, if available
configFile := config.LoadDefaultConfigFile(os.Stderr)
authConfig, err := configFile.GetAuthConfig(registryKey)
if err != nil {
return err
}
authJSON, err := json.Marshal(authConfig)
if err != nil {
return fmt.Errorf("failed to marshal auth config for registry %s: %w", registryKey, err)
}
authStr := base64.URLEncoding.EncodeToString(authJSON)
pushOptions := image.PushOptions{
RegistryAuth: authStr,
}
pushResponse, err := dockerCli.ImagePush(ctx, imageName, pushOptions)
if err != nil {
return fmt.Errorf("failed to push image %s: %w", imageName, err)
}
defer pushResponse.Close()
fmt.Printf("Pushing image %s for service %s...\n", imageName, serviceName)
fd, isTerminal := term.GetFdInfo(os.Stdout)
if err := jsonmessage.DisplayJSONMessagesStream(pushResponse, os.Stdout, fd, isTerminal, nil); err != nil {
return fmt.Errorf("failed to display push response for image %s: %w", imageName, err)
}
fmt.Printf("Image %s pushed successfully.\n", imageName)
return nil
}
// pushServiceImages pushes all built service images to the registry.
func pushServiceImages(ctx context.Context, dockerCli *dockerclient.Client, serviceImages map[string]string) error {
fmt.Printf("Pushing images...\n")
for serviceName, imageName := range serviceImages {
if err := pushSingleServiceImage(ctx, dockerCli, serviceName, imageName); err != nil {
return fmt.Errorf("push image for service %s: %w", serviceName, err)
}
boldStyle := lipgloss.NewStyle().Bold(true)
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
}
return nil
}, cli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
// Collect errors to try pushing all images.
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// ServicesThatNeedBuild returns a list of services that require building.
// deps indicates whether to include services that are dependencies of the selected services.
// Implementation is based on the logic from docker/compose/v2/pkg/compose/build.go.
func ServicesThatNeedBuild(
project *composetypes.Project, selectedServices []string, deps bool,
) ([]composetypes.ServiceConfig, error) {
servicesToBuild := make([]composetypes.ServiceConfig, 0, len(project.Services))
var policy composetypes.DependencyOption = composetypes.IgnoreDependencies
if deps {
policy = composetypes.IncludeDependencies
}
// Also include services used as build.additional_contexts with service: prefix.
selectedServices = includeAdditionalContextsServices(project, selectedServices)
// Some build dependencies we just introduced may not be enabled, enable them.
var err error
if project, err = project.WithServicesEnabled(selectedServices...); err != nil {
return nil, err
}
if project, err = project.WithSelectedServices(selectedServices); err != nil {
return nil, err
}
err = project.ForEachService(selectedServices, func(serviceName string, service *composetypes.ServiceConfig) error {
if service.Build != nil {
servicesToBuild = append(servicesToBuild, *service)
}
return nil
}, policy)
if err != nil {
return nil, err
}
return servicesToBuild, nil
}
// includeAdditionalContextsServices adds services referenced in build.additional_contexts to the list
// of selected services.
func includeAdditionalContextsServices(project *composetypes.Project, selectedServices []string) []string {
servicesWithDependencies := mapset.NewSet(selectedServices...)
for _, service := range selectedServices {
s, ok := project.Services[service]
if !ok {
s = project.DisabledServices[service]
}
if s.Build != nil {
for _, target := range s.Build.AdditionalContexts {
if name, found := strings.CutPrefix(target, composetypes.ServicePrefix); found {
servicesWithDependencies.Add(name)
}
}
}
}
if servicesWithDependencies.Cardinality() > len(selectedServices) {
return includeAdditionalContextsServices(project, servicesWithDependencies.ToSlice())
}
return servicesWithDependencies.ToSlice()
}
+200 -65
View File
@@ -2,7 +2,6 @@ package cli
import (
"context"
"errors"
"fmt"
"net/netip"
"os"
@@ -10,7 +9,6 @@ import (
"github.com/docker/cli/cli/streams"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/fs"
"github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/sshexec"
@@ -26,18 +24,20 @@ const (
// DefaultSSHKeyPath is the fallback location for the SSH private key when provisioning remote machines.
// Used when no key is explicitly provided and SSH agent authentication fails.
DefaultSSHKeyPath = "~/.ssh/id_ed25519"
defaultContextName = "default"
DefaultContextName = "default"
)
type CLI struct {
Config *config.Config
conn *config.MachineConnection
contextOverride string
}
// New creates a new CLI instance with the given config path or remote machine connection.
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
// a cluster without creating a config.
func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
// If a non-empty context name is given, it will override the current default.
func New(configPath string, conn *config.MachineConnection, contextName string) (*CLI, error) {
if conn != nil {
return &CLI{conn: conn}, nil
}
@@ -49,6 +49,7 @@ func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
return &CLI{
Config: cfg,
contextOverride: contextName,
}, nil
}
@@ -70,11 +71,40 @@ func (cli *CLI) SetCurrentContext(name string) error {
return cli.Config.Save()
}
// ConnectCluster connects to a cluster using the given context name or the current context if not specified.
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client.Client, error) {
func (cli *CLI) ContextOverrideOrCurrent() string {
if cli.contextOverride != "" {
return cli.contextOverride
}
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 connectCluster(ctx, *cli.conn)
return cli.conn.String()
}
return ""
}
// ConnectCluster connects to a cluster using the context override or the current context if not specified.
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
func (cli *CLI) ConnectCluster(ctx context.Context) (*client.Client, error) {
return cli.ConnectClusterWithOptions(ctx, ConnectOptions{
// Default to showing progress for CLI usage.
ShowProgress: true,
})
}
// ConnectClusterWithOptions connects to a cluster with the given options.
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
// If the CLI has an override context, it is used instead of the current default.
// Options are useful when using the CLI as a library where you may want to disable visual feedback.
func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, opts ConnectOptions) (*client.Client, error) {
if cli.conn != nil {
return ConnectCluster(ctx, *cli.conn, opts)
}
if len(cli.Config.Contexts) == 0 {
@@ -84,6 +114,8 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client
cli.Config.Path(),
)
}
contextName := cli.contextOverride
if contextName == "" {
// If the cluster is not specified, use the current cluster if set.
if cli.Config.CurrentContext == "" {
@@ -116,38 +148,20 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client
)
}
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
conn := cfg.Connections[0]
c, err := connectCluster(ctx, conn)
if err != nil {
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
}
// Try each connection in order until one succeeds.
var lastErr error
for _, conn := range cfg.Connections {
c, err := ConnectCluster(ctx, conn, opts)
if err == nil {
return c, nil
}
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
if conn.SSH != "" {
user, host, port, err := conn.SSH.Parse()
if err != nil {
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
}
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
sshConfig := &connector.SSHConnectorConfig{
User: user,
Host: host,
Port: port,
KeyPath: keyPath,
}
return client.New(ctx, connector.NewSSHConnector(sshConfig))
} else if conn.TCP != nil && conn.TCP.IsValid() {
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
lastErr = err
}
return nil, errors.New("connection configuration is invalid")
return nil, fmt.Errorf("failed to connect to cluster context '%s': "+
"all connections (%d) in the Uncloud config (%s) failed; last error: %w",
contextName, len(cfg.Connections), cli.Config.Path(), lastErr)
}
type InitClusterOptions struct {
@@ -156,7 +170,10 @@ type InitClusterOptions struct {
Network netip.Prefix
PublicIP *netip.Addr
RemoteMachine *RemoteMachine
SkipInstall bool
Version string
AutoConfirm bool
WireguardEndpoints []*pb.IPPort
}
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
@@ -166,19 +183,16 @@ func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*clie
return cli.initRemoteMachine(ctx, opts)
}
// TODO: implement local machine initialisation
return nil, fmt.Errorf("local machine initialisation is not implemented yet")
return nil, fmt.Errorf("local machine initialisation is not implemented yet. Please specify a remote machine")
}
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
contextName := opts.Context
if contextName == "" {
contextName = defaultContextName
}
if _, ok := cli.Config.Contexts[contextName]; ok {
return nil, fmt.Errorf("cluster context '%s' already exists", contextName)
contextName, err := cli.newContextName(opts.Context)
if err != nil {
return nil, err
}
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
if err != nil {
return nil, err
}
@@ -195,7 +209,12 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
return nil, fmt.Errorf("inspect machine: %w", err)
}
if minfo.Id != "" {
if err = promptResetMachine(ctx, machineClient.MachineClient); err != nil {
if !opts.AutoConfirm {
if err = promptResetMachine(); err != nil {
return nil, err
}
}
if err = resetAndWaitMachine(ctx, machineClient.MachineClient); err != nil {
return nil, err
}
}
@@ -214,6 +233,7 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
req := &pb.InitClusterRequest{
MachineName: opts.MachineName,
Network: pb.NewIPPrefix(opts.Network),
WireguardEndpoints: opts.WireguardEndpoints,
}
if opts.PublicIP != nil {
if opts.PublicIP.IsValid() {
@@ -240,8 +260,21 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
// Save the machine's SSH connection details in the context config.
connCfg := config.MachineConnection{
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
SSHKeyFile: opts.RemoteMachine.KeyPath,
MachineID: resp.Machine.Id,
}
if opts.RemoteMachine.UseSSHCLI {
connCfg.SSHCLI = config.NewSSHDestination(
opts.RemoteMachine.User,
opts.RemoteMachine.Host,
opts.RemoteMachine.Port,
)
} else {
connCfg.SSH = config.NewSSHDestination(
opts.RemoteMachine.User,
opts.RemoteMachine.Host,
opts.RemoteMachine.Port,
)
}
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
if err = cli.Config.Save(); err != nil {
@@ -250,12 +283,40 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
return machineClient, nil
}
// newContextName returns a unique name for a new cluster context. If the provided name is not DefaultContextName,
// and it's already taken, an error is returned. If the name is not provided or is DefaultContextName, the first
// available name "default[-N]" is returned.
func (cli *CLI) newContextName(name string) (string, error) {
if name == "" {
name = DefaultContextName
}
if _, exists := cli.Config.Contexts[name]; !exists {
return name, nil
}
// If non-default context already exists, error out.
if name != DefaultContextName {
return "", fmt.Errorf("cluster context '%s' already exists", name)
}
// The default context already exists, generate a numbered suffix to make it unique.
for i := 1; ; i++ {
name = fmt.Sprintf("%s-%d", DefaultContextName, i)
if _, exists := cli.Config.Contexts[name]; !exists {
return name, nil
}
}
}
type AddMachineOptions struct {
Context string
MachineName string
PublicIP *netip.Addr
RemoteMachine *RemoteMachine
SkipInstall bool
Version string
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.
@@ -263,11 +324,8 @@ type AddMachineOptions struct {
// cluster. The machine client is connected to the new machine and can be used to interact with it.
// Both client should be closed after use by the caller.
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) {
contextName := opts.Context
if contextName == "" {
contextName = cli.Config.CurrentContext
}
c, err := cli.ConnectCluster(ctx, contextName)
contextName := cli.ContextOverrideOrCurrent()
c, err := cli.ConnectCluster(ctx)
if err != nil {
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
}
@@ -277,7 +335,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
}
}()
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
if err != nil {
return nil, nil, err
}
@@ -288,6 +346,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
}()
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
// TODO: refactor to use client.InspectMachine.
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
if err != nil {
return nil, nil, fmt.Errorf("inspect machine: %w", err)
@@ -304,7 +363,12 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
return nil, nil, fmt.Errorf("machine is already a member of this cluster (%s)", minfo.Name)
}
if err = promptResetMachine(ctx, machineClient.MachineClient); err != nil {
if !opts.AutoConfirm {
if err = promptResetMachine(); err != nil {
return nil, nil, err
}
}
if err = resetAndWaitMachine(ctx, machineClient.MachineClient); err != nil {
return nil, nil, err
}
}
@@ -329,11 +393,17 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
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.
endpoints := make([]*pb.IPPort, len(token.Endpoints))
// Register the machine in the cluster using its public key and WireGuard endpoints from the token,
// or use the explicitly provided WireGuard endpoints if any.
var endpoints []*pb.IPPort
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{
Name: opts.MachineName,
Network: &pb.NetworkConfig{
@@ -355,6 +425,18 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
return nil, nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err)
}
// Get the current store DB version from the cluster to pass to the join request.
var storeDBVersion int64
inspectResp, err := c.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
if err != nil {
// TODO(lhf): remove Unimplemented check when v0.17.0 is released.
if status.Convert(err).Code() != codes.Unimplemented {
return nil, nil, fmt.Errorf("inspect current cluster machine: %w", err)
}
} else {
storeDBVersion = inspectResp.Machines[0].StoreDbVersion
}
// Get the most up-to-date list of other machines in the cluster to include them in the join request.
machines, err := c.ListMachines(ctx, nil)
if err != nil {
@@ -371,6 +453,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
joinReq := &pb.JoinClusterRequest{
Machine: addResp.Machine,
OtherMachines: otherMachines,
MinStoreDbVersion: storeDBVersion,
}
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
return nil, nil, fmt.Errorf("join cluster: %w", err)
@@ -381,11 +464,21 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
// Save the machine's SSH connection details in the context config.
connCfg := config.MachineConnection{
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
SSHKeyFile: opts.RemoteMachine.KeyPath,
MachineID: addResp.Machine.Id,
}
if contextName == "" {
contextName = cli.Config.CurrentContext
if opts.RemoteMachine.UseSSHCLI {
connCfg.SSHCLI = config.NewSSHDestination(
opts.RemoteMachine.User,
opts.RemoteMachine.Host,
opts.RemoteMachine.Port,
)
} else {
connCfg.SSH = config.NewSSHDestination(
opts.RemoteMachine.User,
opts.RemoteMachine.Host,
opts.RemoteMachine.Port,
)
}
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
if err = cli.Config.Save(); err != nil {
@@ -395,15 +488,45 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
return c, machineClient, nil
}
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and returns
// a machine API client to interact with the machine. The client should be closed after use by the caller.
// provisionOrConnectRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and
// returns a machine API client to interact with the machine. The client should be closed after use by the caller.
// The version parameter specifies the version of the Uncloud daemon to install. If empty, the latest version is used.
// If skipInstall is true, the installation step is skipped, and it is assumed that the Uncloud daemon and dependencies
// are already installed and running.
// The remoteMachine.SSHKeyPath could be updated to the default SSH key path if it is not set and the SSH agent
// authentication fails.
func provisionRemoteMachine(
ctx context.Context, remoteMachine *RemoteMachine, version string,
func provisionOrConnectRemoteMachine(
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
) (*client.Client, error) {
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
// Use SSH CLI
if remoteMachine.UseSSHCLI {
exec := sshexec.NewSSHCLIRemote(
remoteMachine.User,
remoteMachine.Host,
remoteMachine.Port,
remoteMachine.KeyPath,
)
if !skipInstall {
if err := provisionMachine(ctx, exec, version); err != nil {
return nil, fmt.Errorf("provision machine: %w", err)
}
}
sshConfig := &connector.SSHConnectorConfig{
User: remoteMachine.User,
Host: remoteMachine.Host,
Port: remoteMachine.Port,
KeyPath: remoteMachine.KeyPath,
}
machineClient, err := client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
if err != nil {
return nil, fmt.Errorf("connect to remote machine: %w", err)
}
return machineClient, nil
}
// Use Go SSH
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
// If the SSH connection using SSH agent fails and no key path is provided, try to use the default SSH key.
if err != nil && remoteMachine.KeyPath == "" {
@@ -418,14 +541,17 @@ func provisionRemoteMachine(
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
)
}
if !skipInstall {
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
exec := sshexec.NewRemote(sshClient)
// Install and run the Uncloud daemon and dependencies on the remote machine.
if err = provisionMachine(ctx, exec, version); err != nil {
return nil, fmt.Errorf("provision machine: %w", err)
}
}
var machineClient *client.Client
if remoteMachine.User == "root" {
if remoteMachine.User == "root" || skipInstall {
// Create a machine API client over the established SSH connection to the remote machine.
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
} else {
@@ -445,6 +571,15 @@ func provisionRemoteMachine(
return machineClient, nil
}
// SetClusterContextIfUnset sets the cluster context override only if no --context flag was used
// and no --connect direct connection is active.
func (cli *CLI) SetClusterContextIfUnset(name string) {
if name == "" || cli.contextOverride != "" || cli.conn != nil {
return
}
cli.contextOverride = name
}
// ProgressOut returns an output stream for progress writer.
func (cli *CLI) ProgressOut() *streams.Out {
return streams.NewOut(os.Stdout)
+4
View File
@@ -53,6 +53,10 @@ func (c *Config) Read() error {
func (c *Config) Save() error {
dir, _ := filepath.Split(c.path)
// If dir is empty (e.g., when path is just a filename), use current directory
if dir == "" {
dir = "."
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create config directory '%s': %w", dir, err)
}
+80
View File
@@ -0,0 +1,80 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestConfig_Save(t *testing.T) {
t.Parallel()
// Create a temporary directory for the test
tmpDir := t.TempDir()
// Change to temp directory so relative paths resolve correctly
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current directory: %v", err)
}
defer func() {
if err := os.Chdir(originalDir); err != nil {
t.Logf("Failed to restore original directory: %v", err)
}
}()
tests := []struct {
name string
configPath string
contextName string
expectFileAt string // Expected file location for verification
useAbsolutePath bool // Whether to use absolute path for expectFileAt
}{
{
name: "relative path without prefix",
configPath: "test-config.yaml",
contextName: "test",
},
{
name: "relative path with prefix",
configPath: "./test-config-2.yaml",
contextName: "test2",
},
{
name: "absolute path",
configPath: filepath.Join(tmpDir, "absolute-config.yaml"),
contextName: "test3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
cfg := &Config{
CurrentContext: tt.contextName,
Contexts: map[string]*Context{
tt.contextName: {
Name: tt.contextName,
},
},
path: tt.configPath,
}
// This should not fail when saving the config
err := cfg.Save()
if err != nil {
t.Errorf("Expected no error when saving config, got: %v", err)
}
// Verify the file was created
if _, err := os.Stat(tt.configPath); os.IsNotExist(err) {
t.Errorf("Config file was not created at expected path: %s", tt.configPath)
}
})
}
}
+56 -16
View File
@@ -1,6 +1,8 @@
package config
import (
"errors"
"fmt"
"net"
"net/netip"
"strconv"
@@ -9,52 +11,90 @@ import (
"github.com/psviderski/uncloud/internal/secret"
)
const (
DefaultSSHUser = "root"
DefaultSSHPort = 22
)
type MachineConnection struct {
SSH SSHDestination `yaml:"ssh,omitempty"`
SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"`
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
// TCP is the address and port of the machine's API server.
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
TCP *netip.AddrPort `yaml:"tcp,omitempty"`
// Unix is the path to the machine's API unix socket.
Unix string `yaml:"unix,omitempty"`
Host string `yaml:"host,omitempty"`
PublicKey secret.Secret `yaml:"public_key,omitempty"`
MachineID string `yaml:"machine_id,omitempty"`
}
func (c *MachineConnection) String() string {
if c.SSH != "" {
return "ssh://" + string(c.SSH)
} else if c.SSHCLI != "" {
return "ssh+cli://" + string(c.SSHCLI)
} else if c.TCP != nil && c.TCP.IsValid() {
return fmt.Sprintf("tcp://%s", c.TCP)
} else if c.Unix != "" {
return fmt.Sprintf("unix://%s", c.Unix)
}
return "unknown connection"
}
func (c *MachineConnection) Validate() error {
setCount := 0
if c.SSH != "" {
setCount++
}
if c.SSHCLI != "" {
setCount++
}
if c.TCP != nil && c.TCP.IsValid() {
setCount++
}
if c.Unix != "" {
setCount++
}
if setCount == 0 {
return errors.New("no connection method specified (ssh, ssh_cli, tcp, or unix required)")
}
if setCount > 1 {
return errors.New("only one connection method allowed per connection (ssh, ssh_cli, tcp, or unix)")
}
return nil
}
// 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
// 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 {
dst := host
if port != 0 && port != DefaultSSHPort {
if port != 0 {
dst = net.JoinHostPort(host, strconv.Itoa(port))
}
if user == "" {
user = DefaultSSHUser
if user != "" {
dst = fmt.Sprintf("%s@%s", user, dst)
}
dst = user + "@" + 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) {
host = string(d)
if strings.Contains(host, "@") {
user, host, _ = strings.Cut(host, "@")
}
if user == "" {
user = DefaultSSHUser
}
h, p, sErr := net.SplitHostPort(host)
if sErr == nil {
host = h
port, err = strconv.Atoi(p)
}
if port == 0 {
port = DefaultSSHPort
}
return
}
+205
View File
@@ -0,0 +1,205 @@
package config
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
)
func TestMachineConnection_String(t *testing.T) {
t.Parallel()
tests := []struct {
name string
conn MachineConnection
want string
}{
{
name: "ssh connection",
conn: MachineConnection{
SSH: "user@host.com",
},
want: "ssh://user@host.com",
},
{
name: "ssh connection with port",
conn: MachineConnection{
SSH: "user@host.com:2222",
},
want: "ssh://user@host.com:2222",
},
{
name: "ssh_cli connection",
conn: MachineConnection{
SSHCLI: "user@host.com",
},
want: "ssh+cli://user@host.com",
},
{
name: "ssh_cli connection with port",
conn: MachineConnection{
SSHCLI: "user@host.com:2222",
},
want: "ssh+cli://user@host.com:2222",
},
{
name: "tcp connection",
conn: MachineConnection{
TCP: func() *netip.AddrPort {
addr := netip.MustParseAddrPort("10.0.0.1:8080")
return &addr
}(),
},
want: "tcp://10.0.0.1:8080",
},
{
name: "unix connection",
conn: MachineConnection{
Unix: "/run/uncloud/uncloud.sock",
},
want: "unix:///run/uncloud/uncloud.sock",
},
{
name: "no connection",
conn: MachineConnection{},
want: "unknown connection",
},
{
name: "ssh connection with machine id",
conn: MachineConnection{
SSH: "user@host.com",
MachineID: "ed98b9f7575308c340263cd279e3b568",
},
// String() does not use MachineID; just verifying entry with it is valid
want: "ssh://user@host.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.conn.String()
assert.Equal(t, tt.want, got)
})
}
}
func TestMachineConnection_Validate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
conn MachineConnection
wantErr bool
errMsg string
}{
{
name: "ssh only - valid",
conn: MachineConnection{
SSH: "user@host",
},
wantErr: false,
},
{
name: "ssh_cli only - valid",
conn: MachineConnection{
SSHCLI: "user@host",
},
wantErr: false,
},
{
name: "tcp only - valid",
conn: MachineConnection{
TCP: func() *netip.AddrPort {
addr := netip.MustParseAddrPort("10.0.0.1:8080")
return &addr
}(),
},
wantErr: false,
},
{
name: "unix only - valid",
conn: MachineConnection{
Unix: "/path/to/socket",
},
wantErr: false,
},
{
name: "no connection method - error",
conn: MachineConnection{},
wantErr: true,
errMsg: "no connection method specified",
},
{
name: "ssh and ssh_cli - error",
conn: MachineConnection{
SSH: "user@host",
SSHCLI: "user@host",
},
wantErr: true,
errMsg: "only one connection method allowed",
},
{
name: "ssh and unix - error",
conn: MachineConnection{
SSH: "user@host",
Unix: "/path/to/socket",
},
wantErr: true,
errMsg: "only one connection method allowed",
},
{
name: "ssh and tcp - error",
conn: MachineConnection{
SSH: "user@host",
TCP: func() *netip.AddrPort {
addr := netip.MustParseAddrPort("10.0.0.1:8080")
return &addr
}(),
},
wantErr: true,
errMsg: "only one connection method allowed",
},
{
name: "ssh_cli and tcp - error",
conn: MachineConnection{
SSHCLI: "user@host",
TCP: func() *netip.AddrPort {
addr := netip.MustParseAddrPort("10.0.0.1:8080")
return &addr
}(),
},
wantErr: true,
errMsg: "only one connection method allowed",
},
{
name: "all three - error",
conn: MachineConnection{
SSH: "user@host",
SSHCLI: "user@host2",
TCP: func() *netip.AddrPort {
addr := netip.MustParseAddrPort("10.0.0.1:8080")
return &addr
}(),
},
wantErr: true,
errMsg: "only one connection method allowed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.conn.Validate()
if tt.wantErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
} else {
assert.NoError(t, err)
}
})
}
}
+18
View File
@@ -4,3 +4,21 @@ type Context struct {
Name string `yaml:"-"`
Connections []MachineConnection `yaml:"connections"`
}
func (c *Context) SetDefaultConnection(index int) {
// Do nothing if the index is out of range.
if index < 0 || index >= len(c.Connections) {
return
}
selected := c.Connections[index]
newConnections := make([]MachineConnection, 0, len(c.Connections))
newConnections = append(newConnections, selected)
for i, conn := range c.Connections {
if i != index {
newConnections = append(newConnections, conn)
}
}
c.Connections = newConnections
}
+213
View File
@@ -0,0 +1,213 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"time"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/fs"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/connector"
)
// ConnectOptions configures the behavior of cluster connection attempts.
type ConnectOptions struct {
// Whether to show connection progress spinner if stdout is a terminal or progress logs if not.
ShowProgress bool
}
func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts ConnectOptions) (*client.Client, error) {
if opts.ShowProgress {
return connectClusterWithProgress(ctx, conn)
}
return connectCluster(ctx, conn)
}
// connectClusterWithProgress connects to the cluster while displaying a progress spinner.
// If the stdout is not a terminal, it falls back to simple progress logs to stderr.
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
// If stdout is not a terminal, fall back to simple progress logs.
if !tui.IsStdoutTerminal() {
fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
cli, err := connectCluster(ctx, conn)
if err != nil {
fmt.Fprintln(os.Stderr, "Connection failed:", err)
} else {
fmt.Fprintln(os.Stderr, "Connected to cluster.")
}
return cli, err
}
// Run the connection TUI model.
p := tea.NewProgram(newConnectModel(ctx, conn))
model, err := p.Run()
if err != nil {
return nil, fmt.Errorf("run connection TUI: %w", err)
}
m := model.(connectModel)
return m.result.client, m.result.err
}
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
// Determine which SSH type is configured
var sshDest config.SSHDestination
var useSSHCLI bool
// Validate connection configuration early to provide clear error messages.
if err := conn.Validate(); err != nil {
return nil, fmt.Errorf("invalid connection configuration: %w", err)
}
if conn.SSH != "" {
sshDest = conn.SSH
useSSHCLI = false
} else if conn.SSHCLI != "" {
sshDest = conn.SSHCLI
useSSHCLI = true
} else if conn.TCP != nil && conn.TCP.IsValid() {
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
} else if conn.Unix != "" {
return client.New(ctx, connector.NewUnixConnector(conn.Unix))
} else {
return nil, errors.New("connection configuration is invalid")
}
// Parse SSH destination and create config (shared for both types)
user, host, port, err := sshDest.Parse()
if err != nil {
return nil, fmt.Errorf("parse SSH connection %q: %w", sshDest, err)
}
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
sshConfig := &connector.SSHConnectorConfig{
User: user,
Host: host,
Port: port,
KeyPath: keyPath,
}
// Create appropriate connector based on type
if useSSHCLI {
return client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
}
return client.New(ctx, connector.NewSSHConnector(sshConfig))
}
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
type connectModel struct {
ctx context.Context
conn config.MachineConnection
spinner spinner.Model
// showSpinner controls whether the spinner is visible (delayed to avoid flashing).
showSpinner bool
// done indicates whether the connection attempt has completed (successfully or with error).
done bool
// result holds the result of the connection attempt.
result connectResultMsg
}
type connectResultMsg struct {
client *client.Client
err error
}
// showSpinnerMsg is sent after a delay to show the spinner.
type showSpinnerMsg struct{}
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
s := spinner.New()
s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Yellow) // the same yellow as in compose progress
return connectModel{
ctx: ctx,
conn: conn,
spinner: s,
}
}
func (m connectModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
m.connect(),
m.delayShowSpinner(),
)
}
func (m connectModel) connect() tea.Cmd {
return func() tea.Msg {
cli, err := connectCluster(m.ctx, m.conn)
return connectResultMsg{
client: cli,
err: err,
}
}
}
// delayShowSpinner returns a command that sends a message to show the spinner after a delay.
// This avoids flashing the spinner if the connection is fast.
func (m connectModel) delayShowSpinner() tea.Cmd {
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
return showSpinnerMsg{}
})
}
func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
select {
case <-m.ctx.Done():
m.result.err = m.ctx.Err()
m.done = true
return m, tea.Quit
default:
}
switch msg := msg.(type) {
case connectResultMsg:
m.result = msg
m.done = true
return m, tea.Quit
case showSpinnerMsg:
// Only show spinner if connection hasn't completed yet.
if !m.done {
m.showSpinner = true
}
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
m.result.err = fmt.Errorf("connection cancelled")
m.done = true
return m, tea.Quit
}
}
return m, nil
}
func (m connectModel) View() tea.View {
// Don't show anything if done or spinner not yet visible.
if m.done || !m.showSpinner {
return tea.NewView("")
}
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
return tea.NewView(fmt.Sprintf("%s %s\n",
m.spinner.View(),
fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())),
))
}
+37 -1
View File
@@ -1,7 +1,15 @@
package cli
import (
"fmt"
"log"
"net/netip"
"os"
"strings"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/spf13/cobra"
)
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
@@ -12,7 +20,7 @@ func ExpandCommaSeparatedValues(values []string) []string {
var expanded []string
for _, value := range values {
for _, v := range strings.Split(value, ",") {
for v := range strings.SplitSeq(value, ",") {
if v = strings.TrimSpace(v); v != "" {
expanded = append(expanded, v)
}
@@ -21,3 +29,31 @@ func ExpandCommaSeparatedValues(values []string) []string {
return expanded
}
// BindEnvToFlag assigns the value of an environment variable to the given command flag if the flag has not been set.
func BindEnvToFlag(cmd *cobra.Command, flagName, envVar string) {
if value := os.Getenv(envVar); value != "" && !cmd.Flags().Changed(flagName) {
if err := cmd.Flags().Set(flagName, value); err != nil {
log.Fatalf("Failed to bind environment variable '%s' to flag '%s': %v", envVar, flagName, err)
}
}
}
// 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
}
+16 -2
View File
@@ -2,13 +2,15 @@ package cli
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"charm.land/huh/v2"
"github.com/cenkalti/backoff/v4"
"github.com/charmbracelet/huh"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/sshexec"
"google.golang.org/protobuf/types/known/emptypb"
@@ -25,6 +27,7 @@ type RemoteMachine struct {
Host string
Port int
KeyPath string
UseSSHCLI bool // indicates ssh+cli:// should be used
}
func installCmd(user string, version string) string {
@@ -85,7 +88,13 @@ func provisionMachine(ctx context.Context, exec sshexec.Executor, version string
return nil
}
func promptResetMachine(ctx context.Context, machineClient pb.MachineClient) error {
func promptResetMachine() error {
if !tui.IsStdinTerminal() {
return errors.New("the remote machine is already initialised as a cluster member; " +
"cannot ask to confirm reset in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
}
var confirm bool
form := huh.NewForm(
huh.NewGroup(
@@ -109,10 +118,15 @@ func promptResetMachine(ctx context.Context, machineClient pb.MachineClient) err
return fmt.Errorf("remote machine is already initialised as a cluster member")
}
return nil
}
func resetAndWaitMachine(ctx context.Context, machineClient pb.MachineClient) error {
if _, err := machineClient.Reset(ctx, &pb.ResetRequest{}); err != nil {
return fmt.Errorf("reset remote machine: %w. You can also manually run 'uncloud-uninstall' "+
"on the remote machine to fully uninstall Uncloud from it", err)
}
fmt.Println("Resetting the remote machine...")
if err := waitMachineReady(ctx, machineClient, 1*time.Minute); err != nil {
return fmt.Errorf("wait for machine to be ready after reset: %w", err)
-23
View File
@@ -1,23 +0,0 @@
package cli
import "github.com/charmbracelet/huh"
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
}
+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 {
Status int `json:"status,omitempty"`
Message string `json:"msg,omitempty"`
Data authErrorData `json:"data,omitempty"`
Data authErrorData `json:"data"`
}
type authErrorData struct {
+5
View File
@@ -11,7 +11,12 @@ import (
"github.com/docker/docker/client"
)
type Client struct {
*client.Client
}
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
// TODO(lhf): convert into a Client method.
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
// Retry to ping the Docker daemon until it's ready or the context is canceled.
boff := backoff.WithContext(backoff.NewExponentialBackOff(
+70
View File
@@ -0,0 +1,70 @@
package docker
import (
"context"
"errors"
"fmt"
"time"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/go-connections/nat"
)
// CreateContainerWithImagePull creates a container. If the image is missing, it pulls the image first.
func (cli *Client) CreateContainerWithImagePull(
ctx context.Context, name string, config *container.Config, hostConfig *container.HostConfig,
) (container.CreateResponse, error) {
resp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name)
if err == nil || !errdefs.IsNotFound(err) {
return resp, err
}
pullCh, err := cli.PullImage(ctx, config.Image, image.PullOptions{})
if err != nil {
return resp, fmt.Errorf("pull image: %w", err)
}
// Drain the pull channel until it's closed (image is fully pulled) or an error occurs.
// If the context is canceled during the pull, the channel will receive a context cancellation error.
for msg := range pullCh {
if msg.Err != nil {
return resp, fmt.Errorf("pull image: %w", msg.Err)
}
}
// Create container again after image pull.
if resp, err = cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name); err != nil {
return resp, err
}
return resp, nil
}
// WaitPortPublished waits for a container port to be published on the host which happens asynchronously.
func (cli *Client) WaitPortPublished(ctx context.Context, containerID string, port nat.Port) ([]nat.PortBinding, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
for {
c, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
return nil, fmt.Errorf("inspect container: %w", err)
}
binding, ok := c.NetworkSettings.Ports[port]
if ok && len(binding) > 0 {
return binding, nil
}
select {
case <-time.After(10 * time.Millisecond):
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, errors.New("timeout")
}
return nil, ctx.Err()
}
}
}
+132
View File
@@ -0,0 +1,132 @@
package docker
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
dockercommand "github.com/docker/cli/cli/command"
dockerconfig "github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/pkg/jsonmessage"
)
type PullPushImageMessage struct {
Message jsonmessage.JSONMessage
Err error
}
// PullImage pulls an image and returns a channel to receive progress messages.
func (cli *Client) PullImage(
ctx context.Context, image string, opts image.PullOptions,
) (<-chan PullPushImageMessage, error) {
if opts.RegistryAuth == "" {
// Try to retrieve the authentication token for the image from the default local Docker config file.
if encodedAuth, err := RetrieveLocalDockerRegistryAuth(image); err == nil {
opts.RegistryAuth = encodedAuth
}
}
respBody, err := cli.ImagePull(ctx, image, opts)
if err != nil {
return nil, err
}
ch := make(chan PullPushImageMessage)
go processPullPushImageResp(ctx, respBody, ch)
return ch, nil
}
// PushImage pushes an image and returns a channel to receive progress messages.
func (cli *Client) PushImage(
ctx context.Context, image string, opts image.PushOptions,
) (<-chan PullPushImageMessage, error) {
if opts.RegistryAuth == "" {
// Try to retrieve the authentication token for the image from the default local Docker config file.
encodedAuth, _ := RetrieveLocalDockerRegistryAuth(image)
if encodedAuth == "" {
// If no credentials are found, provide an encoded empty auth config to work around the bug in Docker:
// https://github.com/moby/moby/issues/50729
var err error
if encodedAuth, err = registry.EncodeAuthConfig(registry.AuthConfig{}); err != nil {
return nil, fmt.Errorf("encode empty auth config: %w", err)
}
}
opts.RegistryAuth = encodedAuth
}
respBody, err := cli.ImagePush(ctx, image, opts)
if err != nil {
return nil, err
}
ch := make(chan PullPushImageMessage)
go processPullPushImageResp(ctx, respBody, ch)
return ch, nil
}
// processPullPushImageResp decodes JSON messages from the image pull/push response body and
// sends them to the provided channel.
func processPullPushImageResp(ctx context.Context, respBody io.ReadCloser, ch chan<- PullPushImageMessage) {
defer respBody.Close()
defer close(ch)
decoder := json.NewDecoder(respBody)
for {
var jm jsonmessage.JSONMessage
if err := decoder.Decode(&jm); err != nil {
if errors.Is(err, io.EOF) {
return
}
ch <- PullPushImageMessage{Err: fmt.Errorf("decode image pull/push message: %w", err)}
return
}
msg := PullPushImageMessage{Message: jm}
if jm.Error != nil {
msg.Err = errors.New(jm.Error.Message)
}
select {
case <-ctx.Done():
ch <- PullPushImageMessage{Err: ctx.Err()}
return
default:
ch <- msg
}
}
}
// RetrieveLocalDockerRegistryAuth retrieves the authentication token for the specified image from the local Docker
// config file. It returns the encoded authentication token if it contains any credentials, or an empty string if
// no credentials are found.
func RetrieveLocalDockerRegistryAuth(image string) (string, error) {
// Try to retrieve the authentication token for the image from the default local Docker config file.
dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr)
encodedAuth, err := dockercommand.RetrieveAuthTokenFromImage(dockerConfig, image)
if err != nil {
return "", err
}
// The encodedAuth can be a base64-encoded "{}" (empty JSON object) or include a server address but no credentials.
// Return encodedAuth only if it contains any credentials.
auth, err := registry.DecodeAuthConfig(encodedAuth)
if err != nil {
return "", fmt.Errorf("decode auth config: %w", err)
}
if auth.Username == "" &&
auth.Password == "" &&
auth.Auth == "" &&
auth.IdentityToken == "" &&
auth.RegistryToken == "" {
return "", nil
}
return encodedAuth, nil
}
+98
View File
@@ -0,0 +1,98 @@
package gitutil
import (
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
// GitState contains information about the Git repository state.
type GitState struct {
// Date is the current commit datetime.
Date time.Time
// IsDirty indicates whether there are uncommitted changes.
IsDirty bool
// IsRepo indicates whether the directory is a Git repository.
IsRepo bool
// SHA is the full SHA-1 (40 characters) of the current commit.
SHA string
}
// InspectGitState inspects the Git repo state from the specified directory.
// If the directory is not a Git repository, or if git utility is not available,
// returns GitState with IsRepo=false and no error.
func InspectGitState(dir string) (GitState, error) {
// Check if git command is available.
if _, err := exec.LookPath("git"); err != nil {
return GitState{}, nil
}
state := GitState{
IsRepo: isGitRepo(dir),
}
if !state.IsRepo {
return state, nil
}
// Get the current commit SHA. An initialised but empty repo has no HEAD yet,
// so treat it as a non-repo so callers can fall back to non-git logic.
sha, err := gitCommand(dir, "rev-parse", "--verify", "HEAD")
if err != nil {
state.IsRepo = false
return state, nil
}
state.SHA = strings.TrimSpace(sha)
// Get the current commit timestamp.
timestamp, err := gitCommand(dir, "log", "-1", "--format=%ct")
if err != nil {
return state, fmt.Errorf("get current commit timestamp: %w", err)
}
seconds, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64)
if err != nil {
return state, fmt.Errorf("parse current commit timestamp: %w", err)
}
state.Date = time.Unix(seconds, 0).UTC()
// Check for uncommitted changes.
status, err := gitCommand(dir, "status", "--porcelain")
if err != nil {
return state, fmt.Errorf("check git status: %w", err)
}
state.IsDirty = strings.TrimSpace(status) != ""
return state, nil
}
// isGitRepo checks if the directory is a Git repository.
func isGitRepo(dir string) bool {
_, err := gitCommand(dir, "rev-parse", "--git-dir")
return err == nil
}
// gitCommand runs a git command in the specified directory.
func gitCommand(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("git command failed: %s", exitErr.Stderr)
}
return "", err
}
return string(output), nil
}
// ShortSHA returns a truncated SHA with the specified length.
// If length is negative, zero, or greater than the SHA length, it returns the full SHA.
// Returns empty string if SHA is empty.
func (s *GitState) ShortSHA(length int) string {
if length <= 0 || length > len(s.SHA) {
length = len(s.SHA)
}
return s.SHA[:length]
}
+184
View File
@@ -0,0 +1,184 @@
package gitutil
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInspectGitState_GitNotAvailable(t *testing.T) {
// Reset the PATH env var to simulate git not being available.
t.Setenv("PATH", "")
tmpDir := t.TempDir()
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
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_NotARepo(t *testing.T) {
// Create a temporary directory that's not a git repo.
tmpDir := t.TempDir()
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
// If tmpDir happens to be in a git repo (e.g., parent directory), skip this test.
// This is common when running tests from within the project itself.
if state.IsRepo {
t.Skip("tmpDir is within a git repository, skipping test")
}
assert.False(t, state.IsRepo)
assert.Empty(t, state.SHA)
assert.Empty(t, state.ShortSHA(7))
assert.True(t, state.Date.IsZero()) // Should have zero time if not a repo.
assert.False(t, state.IsDirty)
}
func TestInspectGitState_EmptyRepo(t *testing.T) {
// Create a temporary git repo with no commits.
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
// An empty repo should be treated as a non-repo so that callers fall back to non-git logic.
assert.False(t, state.IsRepo)
assert.Empty(t, state.SHA)
assert.Empty(t, state.ShortSHA(7))
assert.True(t, state.Date.IsZero())
assert.False(t, state.IsDirty)
}
func TestInspectGitState_CleanRepo(t *testing.T) {
// Create a temporary git repo.
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
createAndCommitFile(t, tmpDir, "test.txt", "test content")
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
assert.True(t, state.IsRepo)
assert.Len(t, state.SHA, 40)
assert.Equal(t, state.ShortSHA(7), state.SHA[:7])
assert.False(t, state.Date.IsZero())
assert.False(t, state.IsDirty)
}
func TestInspectGitState_DirtyRepo(t *testing.T) {
// Create a temporary git repo.
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
createAndCommitFile(t, tmpDir, "test.txt", "test content")
// Modify the file to make the repo dirty.
err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("modified content"), 0o644)
require.NoError(t, err)
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
assert.True(t, state.IsRepo)
assert.True(t, state.IsDirty)
}
func TestInspectGitState_UntrackedFiles(t *testing.T) {
// Create a temporary git repo.
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
createAndCommitFile(t, tmpDir, "test.txt", "test content")
// Add an untracked file.
err := os.WriteFile(filepath.Join(tmpDir, "untracked.txt"), []byte("untracked"), 0o644)
require.NoError(t, err)
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
assert.True(t, state.IsRepo)
assert.True(t, state.IsDirty) // Untracked files make the repo dirty.
}
func TestGitState_ShortSHA(t *testing.T) {
state := &GitState{
SHA: "1234567890abcdef1234567890abcdef12345678",
}
tests := []struct {
name string
length int
expected string
}{
{"short 7", 7, "1234567"},
{"short 10", 10, "1234567890"},
{"full SHA", 40, "1234567890abcdef1234567890abcdef12345678"},
{"longer than SHA", 50, "1234567890abcdef1234567890abcdef12345678"},
{"negative", -42, "1234567890abcdef1234567890abcdef12345678"},
{"zero", 0, "1234567890abcdef1234567890abcdef12345678"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := state.ShortSHA(tt.length)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGitState_ShortSHA_Empty(t *testing.T) {
state := &GitState{
SHA: "",
}
result := state.ShortSHA(7)
assert.Empty(t, result)
}
// Helper functions.
func initGitRepo(t *testing.T, dir string) {
t.Helper()
// Initialize git repo.
runGitCommand(t, dir, "init")
runGitCommand(t, dir, "config", "user.email", "test@example.com")
runGitCommand(t, dir, "config", "user.name", "Test User")
}
func createAndCommitFile(t *testing.T, dir, filename, content string) {
t.Helper()
// Create file.
path := filepath.Join(dir, filename)
err := os.WriteFile(path, []byte(content), 0o644)
require.NoError(t, err)
// Commit file.
runGitCommand(t, dir, "add", filename)
runGitCommand(t, dir, "commit", "-m", "Add "+filename)
}
func runGitCommand(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
require.NoError(t, err, "git command failed: %s", output)
}
+19
View File
@@ -0,0 +1,19 @@
package log
import (
"log/slog"
"os"
"slices"
"strings"
)
func InitLoggerFromEnv() {
debugValues := []string{"1", "true", "yes"}
if slices.Contains(debugValues, strings.ToLower(os.Getenv("DEBUG"))) {
logger := slog.New(NewSlogTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
slog.SetDefault(logger)
}
slog.Debug("logger initialized")
}
File diff suppressed because it is too large Load Diff
+82 -1
View File
@@ -5,6 +5,7 @@ package api;
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
import "internal/machine/api/pb/common.proto";
service Docker {
@@ -15,11 +16,15 @@ service Docker {
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
rpc ExecContainer(stream ExecContainerRequest) returns (stream ExecContainerResponse);
rpc ContainerLogs(ContainerLogsRequest) returns (stream ContainerLogEntry);
rpc PullImage(PullImageRequest) returns (stream JSONMessage);
rpc InspectImage(InspectImageRequest) returns (InspectImageResponse);
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
// Docker auth credentials if necessary.
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
@@ -91,6 +96,64 @@ message RemoveContainerRequest {
bytes options = 2;
}
message ExecContainerRequest {
oneof payload {
// Initial configuration for the exec session. Must be sent as the first message.
ExecConfig config = 1;
// Raw stdin data to be written to the exec process.
bytes stdin = 2;
// TTY resize event (only used when TTY is enabled).
ResizeEvent resize = 3;
}
}
message ExecConfig {
// Container ID to execute the command in.
string container_id = 1;
// JSON serialised ExecOptions
bytes options = 2;
}
message ResizeEvent {
uint32 height = 1;
uint32 width = 2;
}
message ExecContainerResponse {
oneof payload {
// Exec instance ID returned after creating the exec.
string exec_id = 1;
// Raw stdout data from the exec process.
bytes stdout = 2;
// Raw stderr data from the exec process (only when TTY is disabled).
bytes stderr = 3;
// Exit code of the exec process. Sent as the final message.
int32 exit_code = 4;
}
}
message ContainerLogsRequest {
string container_id = 1;
// Options for logs retrieval.
bool follow = 2;
int32 tail = 3; // -1 means all
string since = 4; // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
string until = 5; // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
}
message ContainerLogEntry {
enum StreamType {
UNKNOWN = 0;
STDOUT = 1;
STDERR = 2;
HEARTBEAT = 3;
}
StreamType stream = 1;
google.protobuf.Timestamp timestamp = 2;
// Log line content. Empty for heartbeat entries.
bytes message = 3;
}
message PullImageRequest {
string image = 1;
// JSON serialised image.PullOptions.
@@ -113,7 +176,7 @@ message InspectImageResponse {
message Image {
Metadata metadata = 1;
// JSON serialised types.ImageInspect.
// JSON serialised image.InspectResponse.
bytes image = 2;
}
@@ -134,6 +197,24 @@ message RemoteImage {
bytes manifest = 3;
}
message ListImagesRequest {
// JSON serialised image.ListOptions.
bytes options = 1;
}
message ListImagesResponse {
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
repeated MachineImages messages = 1;
}
message MachineImages {
Metadata metadata = 1;
// JSON serialised []image.Summary.
bytes images = 2;
// True if Docker uses the containerd image store, false if it uses its internal image store.
bool containerd_store = 3;
}
message CreateVolumeRequest {
// JSON serialised volume.CreateOptions.
bytes options = 1;
+112 -1
View File
@@ -26,9 +26,12 @@ const (
Docker_StopContainer_FullMethodName = "/api.Docker/StopContainer"
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
Docker_ExecContainer_FullMethodName = "/api.Docker/ExecContainer"
Docker_ContainerLogs_FullMethodName = "/api.Docker/ContainerLogs"
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
@@ -48,11 +51,14 @@ type DockerClient interface {
StopContainer(ctx context.Context, in *StopContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error)
RemoveContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error)
ContainerLogs(ctx context.Context, in *ContainerLogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ContainerLogEntry], error)
PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error)
InspectImage(ctx context.Context, in *InspectImageRequest, opts ...grpc.CallOption) (*InspectImageResponse, error)
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
// Docker auth credentials if necessary.
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error)
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
@@ -130,9 +136,41 @@ func (c *dockerClient) RemoveContainer(ctx context.Context, in *RemoveContainerR
return out, nil
}
func (c *dockerClient) ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[0], Docker_ExecContainer_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[ExecContainerRequest, ExecContainerResponse]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Docker_ExecContainerClient = grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse]
func (c *dockerClient) ContainerLogs(ctx context.Context, in *ContainerLogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ContainerLogEntry], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[1], Docker_ContainerLogs_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[ContainerLogsRequest, ContainerLogEntry]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Docker_ContainerLogsClient = grpc.ServerStreamingClient[ContainerLogEntry]
func (c *dockerClient) PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[0], Docker_PullImage_FullMethodName, cOpts...)
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[2], Docker_PullImage_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
@@ -169,6 +207,16 @@ func (c *dockerClient) InspectRemoteImage(ctx context.Context, in *InspectRemote
return out, nil
}
func (c *dockerClient) ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListImagesResponse)
err := c.cc.Invoke(ctx, Docker_ListImages_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateVolumeResponse)
@@ -249,11 +297,14 @@ type DockerServer interface {
StopContainer(context.Context, *StopContainerRequest) (*emptypb.Empty, error)
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error
ContainerLogs(*ContainerLogsRequest, grpc.ServerStreamingServer[ContainerLogEntry]) error
PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error
InspectImage(context.Context, *InspectImageRequest) (*InspectImageResponse, error)
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
// Docker auth credentials if necessary.
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
@@ -289,6 +340,12 @@ func (UnimplementedDockerServer) ListContainers(context.Context, *ListContainers
func (UnimplementedDockerServer) RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveContainer not implemented")
}
func (UnimplementedDockerServer) ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error {
return status.Errorf(codes.Unimplemented, "method ExecContainer not implemented")
}
func (UnimplementedDockerServer) ContainerLogs(*ContainerLogsRequest, grpc.ServerStreamingServer[ContainerLogEntry]) error {
return status.Errorf(codes.Unimplemented, "method ContainerLogs not implemented")
}
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
return status.Errorf(codes.Unimplemented, "method PullImage not implemented")
}
@@ -298,6 +355,9 @@ func (UnimplementedDockerServer) InspectImage(context.Context, *InspectImageRequ
func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method InspectRemoteImage not implemented")
}
func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented")
}
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
}
@@ -448,6 +508,24 @@ func _Docker_RemoveContainer_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _Docker_ExecContainer_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(DockerServer).ExecContainer(&grpc.GenericServerStream[ExecContainerRequest, ExecContainerResponse]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Docker_ExecContainerServer = grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]
func _Docker_ContainerLogs_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(ContainerLogsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(DockerServer).ContainerLogs(m, &grpc.GenericServerStream[ContainerLogsRequest, ContainerLogEntry]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Docker_ContainerLogsServer = grpc.ServerStreamingServer[ContainerLogEntry]
func _Docker_PullImage_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(PullImageRequest)
if err := stream.RecvMsg(m); err != nil {
@@ -495,6 +573,24 @@ func _Docker_InspectRemoteImage_Handler(srv interface{}, ctx context.Context, de
return interceptor(ctx, in, info, handler)
}
func _Docker_ListImages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListImagesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DockerServer).ListImages(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Docker_ListImages_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DockerServer).ListImages(ctx, req.(*ListImagesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateVolumeRequest)
if err := dec(in); err != nil {
@@ -660,6 +756,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
MethodName: "InspectRemoteImage",
Handler: _Docker_InspectRemoteImage_Handler,
},
{
MethodName: "ListImages",
Handler: _Docker_ListImages_Handler,
},
{
MethodName: "CreateVolume",
Handler: _Docker_CreateVolume_Handler,
@@ -690,6 +790,17 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "ExecContainer",
Handler: _Docker_ExecContainer_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "ContainerLogs",
Handler: _Docker_ContainerLogs_Handler,
ServerStreams: true,
},
{
StreamName: "PullImage",
Handler: _Docker_PullImage_Handler,
+558 -144
View File
@@ -10,6 +10,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
emptypb "google.golang.org/protobuf/types/known/emptypb"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
)
@@ -232,6 +233,8 @@ type InitClusterRequest struct {
// *InitClusterRequest_PublicIp
// *InitClusterRequest_PublicIpAuto
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() {
@@ -301,6 +304,13 @@ func (x *InitClusterRequest) GetPublicIpAuto() bool {
return false
}
func (x *InitClusterRequest) GetWireguardEndpoints() []*IPPort {
if x != nil {
return x.WireguardEndpoints
}
return nil
}
type isInitClusterRequest_PublicIpConfig interface {
isInitClusterRequest_PublicIpConfig()
}
@@ -371,6 +381,8 @@ type JoinClusterRequest struct {
Machine *MachineInfo `protobuf:"bytes,1,opt,name=machine,proto3" json:"machine,omitempty"`
OtherMachines []*MachineInfo `protobuf:"bytes,3,rep,name=other_machines,json=otherMachines,proto3" json:"other_machines,omitempty"`
// Minimum store database version the new machine should sync to before starting cluster operations.
MinStoreDbVersion int64 `protobuf:"varint,4,opt,name=min_store_db_version,json=minStoreDbVersion,proto3" json:"min_store_db_version,omitempty"`
}
func (x *JoinClusterRequest) Reset() {
@@ -419,6 +431,125 @@ func (x *JoinClusterRequest) GetOtherMachines() []*MachineInfo {
return nil
}
func (x *JoinClusterRequest) GetMinStoreDbVersion() int64 {
if x != nil {
return x.MinStoreDbVersion
}
return 0
}
type InspectMachineResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Must contain only one repeated messages field to allow broadcasting InspectMachine requests to multiple machines.
Machines []*MachineDetails `protobuf:"bytes,1,rep,name=machines,proto3" json:"machines,omitempty"`
}
func (x *InspectMachineResponse) Reset() {
*x = InspectMachineResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *InspectMachineResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*InspectMachineResponse) ProtoMessage() {}
func (x *InspectMachineResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use InspectMachineResponse.ProtoReflect.Descriptor instead.
func (*InspectMachineResponse) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{6}
}
func (x *InspectMachineResponse) GetMachines() []*MachineDetails {
if x != nil {
return x.Machines
}
return nil
}
type MachineDetails struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Metadata *Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"`
Machine *MachineInfo `protobuf:"bytes,2,opt,name=machine,proto3" json:"machine,omitempty"`
// Current Corrosion cr-sqlite database version (Lamport timestamp) of the cluster store.
StoreDbVersion int64 `protobuf:"varint,3,opt,name=store_db_version,json=storeDbVersion,proto3" json:"store_db_version,omitempty"`
}
func (x *MachineDetails) Reset() {
*x = MachineDetails{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *MachineDetails) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MachineDetails) ProtoMessage() {}
func (x *MachineDetails) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MachineDetails.ProtoReflect.Descriptor instead.
func (*MachineDetails) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{7}
}
func (x *MachineDetails) GetMetadata() *Metadata {
if x != nil {
return x.Metadata
}
return nil
}
func (x *MachineDetails) GetMachine() *MachineInfo {
if x != nil {
return x.Machine
}
return nil
}
func (x *MachineDetails) GetStoreDbVersion() int64 {
if x != nil {
return x.StoreDbVersion
}
return 0
}
type TokenResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -430,7 +561,7 @@ type TokenResponse struct {
func (x *TokenResponse) Reset() {
*x = TokenResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -443,7 +574,7 @@ func (x *TokenResponse) String() string {
func (*TokenResponse) ProtoMessage() {}
func (x *TokenResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[6]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -456,7 +587,7 @@ func (x *TokenResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use TokenResponse.ProtoReflect.Descriptor instead.
func (*TokenResponse) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{6}
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{8}
}
func (x *TokenResponse) GetToken() string {
@@ -475,7 +606,7 @@ type ResetRequest struct {
func (x *ResetRequest) Reset() {
*x = ResetRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -488,7 +619,7 @@ func (x *ResetRequest) String() string {
func (*ResetRequest) ProtoMessage() {}
func (x *ResetRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[7]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -501,7 +632,7 @@ func (x *ResetRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ResetRequest.ProtoReflect.Descriptor instead.
func (*ResetRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{7}
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{9}
}
type Service struct {
@@ -518,7 +649,7 @@ type Service struct {
func (x *Service) Reset() {
*x = Service{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -531,7 +662,7 @@ func (x *Service) String() string {
func (*Service) ProtoMessage() {}
func (x *Service) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[8]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[10]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -544,7 +675,7 @@ func (x *Service) ProtoReflect() protoreflect.Message {
// Deprecated: Use Service.ProtoReflect.Descriptor instead.
func (*Service) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{8}
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{10}
}
func (x *Service) GetId() string {
@@ -586,7 +717,7 @@ type InspectServiceRequest struct {
func (x *InspectServiceRequest) Reset() {
*x = InspectServiceRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -599,7 +730,7 @@ func (x *InspectServiceRequest) String() string {
func (*InspectServiceRequest) ProtoMessage() {}
func (x *InspectServiceRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[9]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[11]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -612,7 +743,7 @@ func (x *InspectServiceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use InspectServiceRequest.ProtoReflect.Descriptor instead.
func (*InspectServiceRequest) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{9}
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{11}
}
func (x *InspectServiceRequest) GetId() string {
@@ -633,7 +764,7 @@ type InspectServiceResponse struct {
func (x *InspectServiceResponse) Reset() {
*x = InspectServiceResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[10]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -646,7 +777,7 @@ func (x *InspectServiceResponse) String() string {
func (*InspectServiceResponse) ProtoMessage() {}
func (x *InspectServiceResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[10]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[12]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -659,7 +790,7 @@ func (x *InspectServiceResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use InspectServiceResponse.ProtoReflect.Descriptor instead.
func (*InspectServiceResponse) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{10}
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{12}
}
func (x *InspectServiceResponse) GetService() *Service {
@@ -669,6 +800,164 @@ func (x *InspectServiceResponse) GetService() *Service {
return nil
}
type InspectWireGuardNetworkResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
InterfaceName string `protobuf:"bytes,1,opt,name=interface_name,json=interfaceName,proto3" json:"interface_name,omitempty"`
PublicKey []byte `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"`
ListenPort int32 `protobuf:"varint,3,opt,name=listen_port,json=listenPort,proto3" json:"listen_port,omitempty"`
Peers []*WireGuardPeer `protobuf:"bytes,4,rep,name=peers,proto3" json:"peers,omitempty"`
}
func (x *InspectWireGuardNetworkResponse) Reset() {
*x = InspectWireGuardNetworkResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *InspectWireGuardNetworkResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*InspectWireGuardNetworkResponse) ProtoMessage() {}
func (x *InspectWireGuardNetworkResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[13]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use InspectWireGuardNetworkResponse.ProtoReflect.Descriptor instead.
func (*InspectWireGuardNetworkResponse) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{13}
}
func (x *InspectWireGuardNetworkResponse) GetInterfaceName() string {
if x != nil {
return x.InterfaceName
}
return ""
}
func (x *InspectWireGuardNetworkResponse) GetPublicKey() []byte {
if x != nil {
return x.PublicKey
}
return nil
}
func (x *InspectWireGuardNetworkResponse) GetListenPort() int32 {
if x != nil {
return x.ListenPort
}
return 0
}
func (x *InspectWireGuardNetworkResponse) GetPeers() []*WireGuardPeer {
if x != nil {
return x.Peers
}
return nil
}
type WireGuardPeer struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
PublicKey []byte `protobuf:"bytes,1,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"`
Endpoint string `protobuf:"bytes,2,opt,name=endpoint,proto3" json:"endpoint,omitempty"`
LastHandshakeTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=last_handshake_time,json=lastHandshakeTime,proto3" json:"last_handshake_time,omitempty"`
ReceiveBytes int64 `protobuf:"varint,4,opt,name=receive_bytes,json=receiveBytes,proto3" json:"receive_bytes,omitempty"`
TransmitBytes int64 `protobuf:"varint,5,opt,name=transmit_bytes,json=transmitBytes,proto3" json:"transmit_bytes,omitempty"`
AllowedIps []string `protobuf:"bytes,6,rep,name=allowed_ips,json=allowedIps,proto3" json:"allowed_ips,omitempty"`
}
func (x *WireGuardPeer) Reset() {
*x = WireGuardPeer{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *WireGuardPeer) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WireGuardPeer) ProtoMessage() {}
func (x *WireGuardPeer) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[14]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WireGuardPeer.ProtoReflect.Descriptor instead.
func (*WireGuardPeer) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{14}
}
func (x *WireGuardPeer) GetPublicKey() []byte {
if x != nil {
return x.PublicKey
}
return nil
}
func (x *WireGuardPeer) GetEndpoint() string {
if x != nil {
return x.Endpoint
}
return ""
}
func (x *WireGuardPeer) GetLastHandshakeTime() *timestamppb.Timestamp {
if x != nil {
return x.LastHandshakeTime
}
return nil
}
func (x *WireGuardPeer) GetReceiveBytes() int64 {
if x != nil {
return x.ReceiveBytes
}
return 0
}
func (x *WireGuardPeer) GetTransmitBytes() int64 {
if x != nil {
return x.TransmitBytes
}
return 0
}
func (x *WireGuardPeer) GetAllowedIps() []string {
if x != nil {
return x.AllowedIps
}
return nil
}
type Service_Container struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -682,7 +971,7 @@ type Service_Container struct {
func (x *Service_Container) Reset() {
*x = Service_Container{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[11]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -695,7 +984,7 @@ func (x *Service_Container) String() string {
func (*Service_Container) ProtoMessage() {}
func (x *Service_Container) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[11]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[15]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -708,7 +997,7 @@ func (x *Service_Container) ProtoReflect() protoreflect.Message {
// Deprecated: Use Service_Container.ProtoReflect.Descriptor instead.
func (*Service_Container) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{8, 0}
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{10, 0}
}
func (x *Service_Container) GetMachineId() string {
@@ -732,58 +1021,81 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e,
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x1a, 0x1b, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d,
0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x24, 0x69, 0x6e, 0x74, 0x65, 0x72,
0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f,
0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22,
0x85, 0x01, 0x0a, 0x0b, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 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, 0x2c, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
0x6b, 0x12, 0x24, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x04,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x08, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x22, 0xae, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x77,
0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x75, 0x62,
0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74,
0x12, 0x2c, 0x0a, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x69,
0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50,
0x52, 0x0c, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x70, 0x12, 0x29,
0x0a, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x09,
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62,
0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0x50, 0x0a, 0x1a, 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, 0x1c, 0x0a, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73, 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, 0x20,
0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xc3, 0x01, 0x0a, 0x12, 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, 0x65,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e,
0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x72, 0x65,
0x66, 0x69, 0x78, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x26, 0x0a, 0x09,
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 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, 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, 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, 0x79, 0x0a, 0x12, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74,
0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73,
0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x24, 0x69, 0x6e, 0x74, 0x65,
0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69,
0x2f, 0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x22, 0x85, 0x01, 0x0a, 0x0b, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f,
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, 0x2c, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18,
0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4e, 0x65, 0x74, 0x77,
0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f,
0x72, 0x6b, 0x12, 0x24, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18,
0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x08,
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x22, 0xae, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74,
0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x75,
0x62, 0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69,
0x2e, 0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65,
0x74, 0x12, 0x2c, 0x0a, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f,
0x69, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49,
0x50, 0x52, 0x0c, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x70, 0x12,
0x29, 0x0a, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52,
0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75,
0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09,
0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0x50, 0x0a, 0x1a, 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, 0x1c, 0x0a, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73,
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,
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,
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,
0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x72,
0x65, 0x66, 0x69, 0x78, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x26, 0x0a,
0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
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,
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, 0x12, 0x3c, 0x0a,
0x13, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f,
0x69, 0x6e, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69,
0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61,
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, 0x22, 0x25,
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,
0x68, 0x69, 0x6e, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x64, 0x62,
0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e,
0x73, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x25,
0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 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,
@@ -806,39 +1118,77 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
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, 0x32, 0xc3, 0x03, 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, 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,
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, 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 (
@@ -853,7 +1203,7 @@ func file_internal_machine_api_pb_machine_proto_rawDescGZIP() []byte {
return file_internal_machine_api_pb_machine_proto_rawDescData
}
var file_internal_machine_api_pb_machine_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_internal_machine_api_pb_machine_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
var file_internal_machine_api_pb_machine_proto_goTypes = []any{
(*MachineInfo)(nil), // 0: api.MachineInfo
(*NetworkConfig)(nil), // 1: api.NetworkConfig
@@ -861,49 +1211,65 @@ var file_internal_machine_api_pb_machine_proto_goTypes = []any{
(*InitClusterRequest)(nil), // 3: api.InitClusterRequest
(*InitClusterResponse)(nil), // 4: api.InitClusterResponse
(*JoinClusterRequest)(nil), // 5: api.JoinClusterRequest
(*TokenResponse)(nil), // 6: api.TokenResponse
(*ResetRequest)(nil), // 7: api.ResetRequest
(*Service)(nil), // 8: api.Service
(*InspectServiceRequest)(nil), // 9: api.InspectServiceRequest
(*InspectServiceResponse)(nil), // 10: api.InspectServiceResponse
(*Service_Container)(nil), // 11: api.Service.Container
(*IP)(nil), // 12: api.IP
(*IPPrefix)(nil), // 13: api.IPPrefix
(*IPPort)(nil), // 14: api.IPPort
(*emptypb.Empty)(nil), // 15: google.protobuf.Empty
(*InspectMachineResponse)(nil), // 6: api.InspectMachineResponse
(*MachineDetails)(nil), // 7: api.MachineDetails
(*TokenResponse)(nil), // 8: api.TokenResponse
(*ResetRequest)(nil), // 9: api.ResetRequest
(*Service)(nil), // 10: api.Service
(*InspectServiceRequest)(nil), // 11: api.InspectServiceRequest
(*InspectServiceResponse)(nil), // 12: api.InspectServiceResponse
(*InspectWireGuardNetworkResponse)(nil), // 13: api.InspectWireGuardNetworkResponse
(*WireGuardPeer)(nil), // 14: api.WireGuardPeer
(*Service_Container)(nil), // 15: api.Service.Container
(*IP)(nil), // 16: api.IP
(*IPPrefix)(nil), // 17: api.IPPrefix
(*IPPort)(nil), // 18: api.IPPort
(*Metadata)(nil), // 19: api.Metadata
(*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp
(*emptypb.Empty)(nil), // 21: google.protobuf.Empty
}
var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
1, // 0: api.MachineInfo.network:type_name -> api.NetworkConfig
12, // 1: api.MachineInfo.public_ip:type_name -> api.IP
13, // 2: api.NetworkConfig.subnet:type_name -> api.IPPrefix
12, // 3: api.NetworkConfig.management_ip:type_name -> api.IP
14, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort
13, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix
12, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP
0, // 7: api.InitClusterResponse.machine:type_name -> api.MachineInfo
0, // 8: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
0, // 9: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
11, // 10: api.Service.containers:type_name -> api.Service.Container
8, // 11: api.InspectServiceResponse.service:type_name -> api.Service
15, // 12: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
3, // 13: api.Machine.InitCluster:input_type -> api.InitClusterRequest
5, // 14: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest
15, // 15: api.Machine.Token:input_type -> google.protobuf.Empty
15, // 16: api.Machine.Inspect:input_type -> google.protobuf.Empty
7, // 17: api.Machine.Reset:input_type -> api.ResetRequest
9, // 18: api.Machine.InspectService:input_type -> api.InspectServiceRequest
2, // 19: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
4, // 20: api.Machine.InitCluster:output_type -> api.InitClusterResponse
15, // 21: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
6, // 22: api.Machine.Token:output_type -> api.TokenResponse
0, // 23: api.Machine.Inspect:output_type -> api.MachineInfo
15, // 24: api.Machine.Reset:output_type -> google.protobuf.Empty
10, // 25: api.Machine.InspectService:output_type -> api.InspectServiceResponse
19, // [19:26] is the sub-list for method output_type
12, // [12:19] is the sub-list for method input_type
12, // [12:12] is the sub-list for extension type_name
12, // [12:12] is the sub-list for extension extendee
0, // [0:12] is the sub-list for field type_name
16, // 1: api.MachineInfo.public_ip:type_name -> api.IP
17, // 2: api.NetworkConfig.subnet:type_name -> api.IPPrefix
16, // 3: api.NetworkConfig.management_ip:type_name -> api.IP
18, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort
17, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix
16, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP
18, // 7: api.InitClusterRequest.wireguard_endpoints:type_name -> api.IPPort
0, // 8: api.InitClusterResponse.machine:type_name -> api.MachineInfo
0, // 9: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
0, // 10: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
7, // 11: api.InspectMachineResponse.machines:type_name -> api.MachineDetails
19, // 12: api.MachineDetails.metadata:type_name -> api.Metadata
0, // 13: api.MachineDetails.machine:type_name -> api.MachineInfo
15, // 14: api.Service.containers:type_name -> api.Service.Container
10, // 15: api.InspectServiceResponse.service:type_name -> api.Service
14, // 16: api.InspectWireGuardNetworkResponse.peers:type_name -> api.WireGuardPeer
20, // 17: api.WireGuardPeer.last_handshake_time:type_name -> google.protobuf.Timestamp
21, // 18: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
3, // 19: api.Machine.InitCluster:input_type -> api.InitClusterRequest
5, // 20: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest
21, // 21: api.Machine.Token:input_type -> google.protobuf.Empty
21, // 22: api.Machine.Inspect:input_type -> google.protobuf.Empty
21, // 23: api.Machine.InspectMachine:input_type -> google.protobuf.Empty
21, // 24: api.Machine.InspectWireGuardNetwork:input_type -> google.protobuf.Empty
9, // 25: api.Machine.Reset:input_type -> api.ResetRequest
11, // 26: api.Machine.InspectService:input_type -> api.InspectServiceRequest
2, // 27: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
4, // 28: api.Machine.InitCluster:output_type -> api.InitClusterResponse
21, // 29: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
8, // 30: api.Machine.Token:output_type -> api.TokenResponse
0, // 31: api.Machine.Inspect:output_type -> api.MachineInfo
6, // 32: api.Machine.InspectMachine:output_type -> api.InspectMachineResponse
13, // 33: api.Machine.InspectWireGuardNetwork:output_type -> api.InspectWireGuardNetworkResponse
21, // 34: api.Machine.Reset:output_type -> google.protobuf.Empty
12, // 35: api.Machine.InspectService:output_type -> api.InspectServiceResponse
27, // [27:36] is the sub-list for method output_type
18, // [18:27] is the sub-list for method input_type
18, // [18:18] is the sub-list for extension 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() }
@@ -986,7 +1352,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*TokenResponse); i {
switch v := v.(*InspectMachineResponse); i {
case 0:
return &v.state
case 1:
@@ -998,7 +1364,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*ResetRequest); i {
switch v := v.(*MachineDetails); i {
case 0:
return &v.state
case 1:
@@ -1010,7 +1376,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*Service); i {
switch v := v.(*TokenResponse); i {
case 0:
return &v.state
case 1:
@@ -1022,7 +1388,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*InspectServiceRequest); i {
switch v := v.(*ResetRequest); i {
case 0:
return &v.state
case 1:
@@ -1034,7 +1400,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[10].Exporter = func(v any, i int) any {
switch v := v.(*InspectServiceResponse); i {
switch v := v.(*Service); i {
case 0:
return &v.state
case 1:
@@ -1046,6 +1412,54 @@ func file_internal_machine_api_pb_machine_proto_init() {
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[11].Exporter = func(v any, i int) any {
switch v := v.(*InspectServiceRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[12].Exporter = func(v any, i int) any {
switch v := v.(*InspectServiceResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[13].Exporter = func(v any, i int) any {
switch v := v.(*InspectWireGuardNetworkResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[14].Exporter = func(v any, i int) any {
switch v := v.(*WireGuardPeer); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[15].Exporter = func(v any, i int) any {
switch v := v.(*Service_Container); i {
case 0:
return &v.state
@@ -1068,7 +1482,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_internal_machine_api_pb_machine_proto_rawDesc,
NumEnums: 0,
NumMessages: 12,
NumMessages: 16,
NumExtensions: 0,
NumServices: 1,
},
+39
View File
@@ -5,6 +5,7 @@ package api;
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
import "internal/machine/api/pb/common.proto";
service Machine {
@@ -13,7 +14,12 @@ service Machine {
rpc InitCluster(InitClusterRequest) returns (InitClusterResponse);
rpc JoinCluster(JoinClusterRequest) returns (google.protobuf.Empty);
rpc Token(google.protobuf.Empty) returns (TokenResponse);
// Deprecated: use InspectMachine instead.
rpc Inspect(google.protobuf.Empty) returns (MachineInfo);
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse);
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
rpc InspectWireGuardNetwork(google.protobuf.Empty) returns (InspectWireGuardNetworkResponse);
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
rpc Reset(ResetRequest) returns (google.protobuf.Empty);
@@ -49,6 +55,9 @@ message InitClusterRequest {
IP public_ip = 3;
bool public_ip_auto = 4;
}
// Optional WireGuard endpoints other machines will use to connect to this machine instead of auto-discovered ones.
repeated IPPort wireguard_endpoints = 5;
}
message InitClusterResponse {
@@ -58,6 +67,20 @@ message InitClusterResponse {
message JoinClusterRequest {
MachineInfo machine = 1;
repeated MachineInfo other_machines = 3;
// Minimum store database version the new machine should sync to before starting cluster operations.
int64 min_store_db_version = 4;
}
message InspectMachineResponse {
// Must contain only one repeated messages field to allow broadcasting InspectMachine requests to multiple machines.
repeated MachineDetails machines = 1;
}
message MachineDetails {
Metadata metadata = 1;
MachineInfo machine = 2;
// Current Corrosion cr-sqlite database version (Lamport timestamp) of the cluster store.
int64 store_db_version = 3;
}
message TokenResponse {
@@ -88,3 +111,19 @@ message InspectServiceRequest {
message InspectServiceResponse {
Service service = 1;
}
message InspectWireGuardNetworkResponse {
string interface_name = 1;
bytes public_key = 2;
int32 listen_port = 3;
repeated WireGuardPeer peers = 4;
}
message WireGuardPeer {
bytes public_key = 1;
string endpoint = 2;
google.protobuf.Timestamp last_handshake_time = 3;
int64 receive_bytes = 4;
int64 transmit_bytes = 5;
repeated string allowed_ips = 6;
}
@@ -25,6 +25,8 @@ const (
Machine_JoinCluster_FullMethodName = "/api.Machine/JoinCluster"
Machine_Token_FullMethodName = "/api.Machine/Token"
Machine_Inspect_FullMethodName = "/api.Machine/Inspect"
Machine_InspectMachine_FullMethodName = "/api.Machine/InspectMachine"
Machine_InspectWireGuardNetwork_FullMethodName = "/api.Machine/InspectWireGuardNetwork"
Machine_Reset_FullMethodName = "/api.Machine/Reset"
Machine_InspectService_FullMethodName = "/api.Machine/InspectService"
)
@@ -38,7 +40,12 @@ type MachineClient interface {
InitCluster(ctx context.Context, in *InitClusterRequest, opts ...grpc.CallOption) (*InitClusterResponse, error)
JoinCluster(ctx context.Context, in *JoinClusterRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
Token(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*TokenResponse, error)
// Deprecated: use InspectMachine instead.
Inspect(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*MachineInfo, error)
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error)
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
InspectWireGuardNetwork(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectWireGuardNetworkResponse, error)
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
Reset(ctx context.Context, in *ResetRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
InspectService(ctx context.Context, in *InspectServiceRequest, opts ...grpc.CallOption) (*InspectServiceResponse, error)
@@ -102,6 +109,26 @@ func (c *machineClient) Inspect(ctx context.Context, in *emptypb.Empty, opts ...
return out, nil
}
func (c *machineClient) InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(InspectMachineResponse)
err := c.cc.Invoke(ctx, Machine_InspectMachine_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *machineClient) InspectWireGuardNetwork(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectWireGuardNetworkResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(InspectWireGuardNetworkResponse)
err := c.cc.Invoke(ctx, Machine_InspectWireGuardNetwork_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *machineClient) Reset(ctx context.Context, in *ResetRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
@@ -131,7 +158,12 @@ type MachineServer interface {
InitCluster(context.Context, *InitClusterRequest) (*InitClusterResponse, error)
JoinCluster(context.Context, *JoinClusterRequest) (*emptypb.Empty, error)
Token(context.Context, *emptypb.Empty) (*TokenResponse, error)
// Deprecated: use InspectMachine instead.
Inspect(context.Context, *emptypb.Empty) (*MachineInfo, error)
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error)
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
InspectWireGuardNetwork(context.Context, *emptypb.Empty) (*InspectWireGuardNetworkResponse, error)
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
Reset(context.Context, *ResetRequest) (*emptypb.Empty, error)
InspectService(context.Context, *InspectServiceRequest) (*InspectServiceResponse, error)
@@ -160,6 +192,12 @@ func (UnimplementedMachineServer) Token(context.Context, *emptypb.Empty) (*Token
func (UnimplementedMachineServer) Inspect(context.Context, *emptypb.Empty) (*MachineInfo, error) {
return nil, status.Errorf(codes.Unimplemented, "method Inspect not implemented")
}
func (UnimplementedMachineServer) InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method InspectMachine not implemented")
}
func (UnimplementedMachineServer) InspectWireGuardNetwork(context.Context, *emptypb.Empty) (*InspectWireGuardNetworkResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method InspectWireGuardNetwork not implemented")
}
func (UnimplementedMachineServer) Reset(context.Context, *ResetRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method Reset not implemented")
}
@@ -277,6 +315,42 @@ func _Machine_Inspect_Handler(srv interface{}, ctx context.Context, dec func(int
return interceptor(ctx, in, info, handler)
}
func _Machine_InspectMachine_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(emptypb.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MachineServer).InspectMachine(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Machine_InspectMachine_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MachineServer).InspectMachine(ctx, req.(*emptypb.Empty))
}
return interceptor(ctx, in, info, handler)
}
func _Machine_InspectWireGuardNetwork_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(emptypb.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MachineServer).InspectWireGuardNetwork(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Machine_InspectWireGuardNetwork_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MachineServer).InspectWireGuardNetwork(ctx, req.(*emptypb.Empty))
}
return interceptor(ctx, in, info, handler)
}
func _Machine_Reset_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResetRequest)
if err := dec(in); err != nil {
@@ -340,6 +414,14 @@ var Machine_ServiceDesc = grpc.ServiceDesc{
MethodName: "Inspect",
Handler: _Machine_Inspect_Handler,
},
{
MethodName: "InspectMachine",
Handler: _Machine_InspectMachine_Handler,
},
{
MethodName: "InspectWireGuardNetwork",
Handler: _Machine_InspectWireGuardNetwork_Handler,
},
{
MethodName: "Reset",
Handler: _Machine_Reset_Handler,
+1 -1
View File
@@ -107,7 +107,7 @@ func (d *Director) remoteBackend(addr string) (*RemoteBackend, error) {
// FlushRemoteBackends closes all remote backend connections and removes them from the cache.
func (d *Director) FlushRemoteBackends() {
d.remoteBackends.Range(func(key, value interface{}) bool {
d.remoteBackends.Range(func(key, value any) bool {
backend, ok := value.(*RemoteBackend)
if !ok {
return true
+3 -1
View File
@@ -80,6 +80,8 @@ func (b *RemoteBackend) GetConnection(ctx context.Context, _ string) (context.Co
backoffConfig.MaxDelay = 15 * time.Second
var err error
// This client keeps retrying connection in the background indefinitely even after the first connection fails
// and the Unavailable error is returned to the caller.
b.conn, err = grpc.NewClient(
b.target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
@@ -88,7 +90,7 @@ func (b *RemoteBackend) GetConnection(ctx context.Context, _ string) (context.Co
// Not published as a constant in gRPC library.
// See: https://github.com/grpc/grpc-go/blob/d5dee5fdbdeb52f6ea10b37b2cc7ce37814642d7/clientconn.go#L55-L56
// Each connection attempt can take up to MinConnectTimeout.
MinConnectTimeout: 20 * time.Second,
MinConnectTimeout: 10 * time.Second,
}),
grpc.WithDefaultCallOptions(
grpc.ForceCodecV2(proxy.Codec()),
+23 -6
View File
@@ -12,14 +12,16 @@ import (
"strconv"
"strings"
"text/template"
"time"
"github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/uncloud/pkg/api"
)
const (
caddyfileHeader = `# This file is autogenerated by Uncloud based on the configuration of running services.
# Do not edit manually. Any manual changes will be overwritten on the next update.
caddyfileHeaderFmt = `# Caddyfile autogenerated by Uncloud (DO NOT EDIT): %s
# Automatically updated on service or health status changes.
# Docs: https://uncloud.run/docs/concepts/ingress/overview
`
caddyfileTemplate = `# Health check endpoint to verify Caddy reachability on this machine.
http:// {
@@ -54,6 +56,11 @@ https://{{$hostname}} {
}
log
}{{end}}
`
caddyfileUnavailabeFooter = `# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
# (not accessible via the shared admin socket /run/uncloud/caddy/admin.sock) or the latest
# generated config is invalid. Please check the service 'caddy' is running (uc inspect caddy)
# and its logs for more details (uc logs caddy).
`
)
@@ -95,7 +102,11 @@ func NewCaddyfileGenerator(machineID string, validator CaddyfileValidator, log *
// [service-a x-caddy]
// ...
// [service-z x-caddy]
func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.ContainerRecord) (string, error) {
//
// If includeCustom is false, custom Caddy configs (x-caddy) are not included in the generated Caddyfile.
func (g *CaddyfileGenerator) Generate(
ctx context.Context, records []store.ContainerRecord, includeCustom bool,
) (string, error) {
containers := make([]api.ServiceContainer, len(records))
for i, cr := range records {
containers[i] = cr.Container
@@ -113,6 +124,11 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
return "", fmt.Errorf("generate base Caddyfile from service ports: %w", err)
}
caddyfileHeader := fmt.Sprintf(caddyfileHeaderFmt, time.Now().UTC().Format(time.RFC3339))
if !includeCustom {
return fmt.Sprintf("%s\n%s\n%s", caddyfileHeader, caddyfile, caddyfileUnavailabeFooter), nil
}
upstreams := serviceUpstreams(containers)
// Track validation errors for reporting.
var configErrors []string
@@ -210,12 +226,13 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
// Append error summary as comment if there were any invalid configs.
if len(configErrors) > 0 {
errorsComment := "# Skipped invalid user-defined configs:\n"
var errorsComment strings.Builder
errorsComment.WriteString("# Skipped invalid user-defined configs:\n")
for _, e := range configErrors {
errorsComment += fmt.Sprintf("# - %s\n", e)
errorsComment.WriteString(fmt.Sprintf("# - %s\n", e))
}
caddyfile += "\n" + errorsComment
caddyfile += "\n" + errorsComment.String()
}
return caddyfileHeader + "\n" + caddyfile, nil
+145 -58
View File
@@ -3,11 +3,11 @@ package caddyconfig
import (
"context"
"errors"
"regexp"
"strings"
"testing"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/psviderski/uncloud/internal/machine/docker"
@@ -18,9 +18,17 @@ import (
"github.com/stretchr/testify/require"
)
func TestCaddyfileGenerator(t *testing.T) {
caddyfileHeader := `# This file is autogenerated by Uncloud based on the configuration of running services.
# Do not edit manually. Any manual changes will be overwritten on the next update.
// generatedTimestampRegex matches the "(DO NOT EDIT): <RFC3339 timestamp>" part of the header.
var generatedTimestampRegex = regexp.MustCompile(`\(DO NOT EDIT\): \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`)
// normaliseGeneratedTimestamp replaces dynamic timestamps with a placeholder for test comparison.
func normaliseGeneratedTimestamp(caddyfile string) string {
return generatedTimestampRegex.ReplaceAllString(caddyfile, "(DO NOT EDIT): TIMESTAMP_PLACEHOLDER")
}
const testCaddyfileHeader = `# Caddyfile autogenerated by Uncloud (DO NOT EDIT): TIMESTAMP_PLACEHOLDER
# Automatically updated on service or health status changes.
# Docs: https://uncloud.run/docs/concepts/ingress/overview
# Health check endpoint to verify Caddy reachability on this machine.
http:// {
@@ -38,6 +46,7 @@ http:// {
}
`
func TestCaddyfileGenerator(t *testing.T) {
tests := []struct {
name string
containers []store.ContainerRecord
@@ -47,14 +56,14 @@ http:// {
{
name: "empty containers",
containers: []store.ContainerRecord{},
want: caddyfileHeader,
want: testCaddyfileHeader,
},
{
name: "HTTP container",
containers: []store.ContainerRecord{
newContainerRecord(newContainer("10.210.0.2", "app.example.com:8080/http"), "mach1"),
},
want: caddyfileHeader + `
want: testCaddyfileHeader + `
# Sites generated from service ports.
http://app.example.com {
@@ -71,7 +80,7 @@ http://app.example.com {
newContainerRecord(newContainer("10.210.0.2", "app.example.com:8080/http"), "mach1"),
newContainerRecord(newContainer("10.210.0.3", "app.example.com:8080/http"), "mach1"),
},
want: caddyfileHeader + `
want: testCaddyfileHeader + `
# Sites generated from service ports.
http://app.example.com {
@@ -87,7 +96,7 @@ http://app.example.com {
containers: []store.ContainerRecord{
newContainerRecord(newContainer("10.210.0.2", "secure.example.com:8000/https"), "mach1"),
},
want: caddyfileHeader + `
want: testCaddyfileHeader + `
# Sites generated from service ports.
https://secure.example.com {
@@ -127,7 +136,7 @@ https://secure.example.com {
"mach1",
),
},
want: caddyfileHeader + `
want: testCaddyfileHeader + `
# Sites generated from service ports.
http://app.example.com {
@@ -157,14 +166,14 @@ https://secure.example.com {
containers: []store.ContainerRecord{
newContainerRecord(newContainerWithoutNetwork("ignored.example.com:8080/http"), "mach1"),
},
want: caddyfileHeader,
want: testCaddyfileHeader,
},
{
name: "container with invalid port ignored",
containers: []store.ContainerRecord{
newContainerRecord(newContainer("10.210.0.2", "invalid-port"), "mach1"),
},
want: caddyfileHeader,
want: testCaddyfileHeader,
},
{
name: "containers with unsupported protocols and host mode ignored",
@@ -173,7 +182,7 @@ https://secure.example.com {
newContainerRecord(newContainer("10.210.0.3", "5000/udp"), "mach1"),
newContainerRecord(newContainer("10.210.0.4", "80:8080/tcp@host"), "mach1"),
},
want: caddyfileHeader,
want: testCaddyfileHeader,
},
}
@@ -183,7 +192,7 @@ https://secure.example.com {
// Validator is not expected to be called in these tests.
generator := NewCaddyfileGenerator("test-machine-id", nil, nil)
config, err := generator.Generate(ctx, tt.containers)
config, err := generator.Generate(ctx, tt.containers, true)
if tt.wantErr {
assert.Error(t, err)
@@ -191,31 +200,12 @@ https://secure.example.com {
}
require.NoError(t, err)
assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match")
assert.Equal(t, tt.want, normaliseGeneratedTimestamp(config), "Generated Caddyfile doesn't match")
})
}
}
func TestCaddyfileGeneratorWithCustomConfigs(t *testing.T) {
caddyfileBase := `# This file is autogenerated by Uncloud based on the configuration of running services.
# Do not edit manually. Any manual changes will be overwritten on the next update.
# Health check endpoint to verify Caddy reachability on this machine.
http:// {
handle /.uncloud-verify {
respond "test-machine-id" 200
}
log
}
(common_proxy) {
# Retry failed requests up to lb_retries times against other available upstreams.
lb_retries 3
# Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking).
fail_duration 30s
}
`
tests := []struct {
name string
containers []store.ContainerRecord
@@ -236,8 +226,9 @@ http:// {
time.Now(),
),
},
want: `# This file is autogenerated by Uncloud based on the configuration of running services.
# Do not edit manually. Any manual changes will be overwritten on the next update.
want: `# Caddyfile autogenerated by Uncloud (DO NOT EDIT): TIMESTAMP_PLACEHOLDER
# Automatically updated on service or health status changes.
# Docs: https://uncloud.run/docs/concepts/ingress/overview
# User-defined global config from service 'caddy'.
# Global Caddy configuration
@@ -275,7 +266,7 @@ web.example.com {
time.Now(),
),
},
want: caddyfileBase + `
want: testCaddyfileHeader + `
# User-defined config for service 'web'.
# Custom config for web service
web.example.com {
@@ -297,7 +288,7 @@ bad.config.com {
time.Now(),
),
},
want: caddyfileBase + `
want: testCaddyfileHeader + `
# Skipped invalid user-defined configs:
# - service 'bad-service': validation failed: invalid config detected
`,
@@ -316,7 +307,7 @@ bad.template.com {
time.Now(),
),
},
want: caddyfileBase + `
want: testCaddyfileHeader + `
# Skipped invalid user-defined configs:
# - service 'bad-template': failed to render template: parse config as Go template: template: Caddyfile:3: unexpected "}" in operand
`,
@@ -335,7 +326,7 @@ localhost {
time.Now(),
),
},
want: caddyfileBase + `
want: testCaddyfileHeader + `
# Skipped invalid user-defined configs:
# - service 'caddy': validation failed: invalid config detected
`,
@@ -354,7 +345,7 @@ localhost {
time.Now(),
),
},
want: caddyfileBase,
want: testCaddyfileHeader,
},
{
name: "multiple services with mixed valid and invalid configs",
@@ -388,7 +379,7 @@ bad.example.com {
time.Now(),
),
},
want: caddyfileBase + `
want: testCaddyfileHeader + `
# User-defined config for service 'api'.
api.example.com {
reverse_proxy api:8080
@@ -432,8 +423,9 @@ web.example.com {
time.Now(),
),
},
want: `# This file is autogenerated by Uncloud based on the configuration of running services.
# Do not edit manually. Any manual changes will be overwritten on the next update.
want: `# Caddyfile autogenerated by Uncloud (DO NOT EDIT): TIMESTAMP_PLACEHOLDER
# Automatically updated on service or health status changes.
# Docs: https://uncloud.run/docs/concepts/ingress/overview
# User-defined global config from service 'caddy'.
# Global config
@@ -490,7 +482,7 @@ api.example.com {
"test-machine-id",
),
},
want: caddyfileBase + `
want: testCaddyfileHeader + `
# Sites generated from service ports.
http://api.example.com {
@@ -530,7 +522,7 @@ new.example.com {
time.Now(),
),
},
want: caddyfileBase + `
want: testCaddyfileHeader + `
# User-defined config for service 'web'.
# New config
new.example.com {
@@ -669,8 +661,9 @@ badconfig.com {
time.Now(),
),
},
want: `# This file is autogenerated by Uncloud based on the configuration of running services.
# Do not edit manually. Any manual changes will be overwritten on the next update.
want: `# Caddyfile autogenerated by Uncloud (DO NOT EDIT): TIMESTAMP_PLACEHOLDER
# Automatically updated on service or health status changes.
# Docs: https://uncloud.run/docs/concepts/ingress/overview
# User-defined global config from service 'caddy'.
# Global config from test machine
@@ -815,7 +808,7 @@ invalid.example.com {
time.Now(),
),
},
want: caddyfileBase + `
want: testCaddyfileHeader + `
# User-defined config for service 'valid'.
valid.example.com {
respond "Valid config"
@@ -843,7 +836,7 @@ valid.example.com {
t.Run(tt.name, func(t *testing.T) {
generator := NewCaddyfileGenerator("test-machine-id", validator, nil)
config, err := generator.Generate(ctx, tt.containers)
config, err := generator.Generate(ctx, tt.containers, true)
if tt.wantErr {
assert.Error(t, err)
@@ -851,7 +844,7 @@ valid.example.com {
}
require.NoError(t, err)
assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match")
assert.Equal(t, tt.want, normaliseGeneratedTimestamp(config), "Generated Caddyfile doesn't match")
})
}
}
@@ -867,15 +860,15 @@ func newContainerRecordWithCaddyConfig(serviceName, ip, caddyConfig, machineID s
return store.ContainerRecord{
Container: api.ServiceContainer{
Container: api.Container{
ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
ID: serviceName + "-" + ip, // Add ID for stable sorting
State: &types.ContainerState{
State: &container.State{
Running: true,
},
Created: created.UTC().Format(time.RFC3339Nano),
},
NetworkSettings: &types.NetworkSettings{
NetworkSettings: &container.NetworkSettings{
Networks: map[string]*network.EndpointSettings{
docker.NetworkName: {
IPAddress: ip,
@@ -899,20 +892,114 @@ func newContainerRecordWithCaddyConfig(serviceName, ip, caddyConfig, machineID s
}
}
func TestCaddyfileGeneratorWithoutCustomConfigs(t *testing.T) {
// Test that when includeCustom is false (Caddy not available), x-caddy configs are skipped.
tests := []struct {
name string
containers []store.ContainerRecord
want string
}{
{
name: "x-caddy configs are skipped",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"caddy",
"10.210.0.1",
`# Global config
{
global directive
}`,
"test-machine-id",
time.Now(),
),
newContainerRecordWithCaddyConfig(
"web",
"10.210.0.2",
`web.example.com {
reverse_proxy web:3000
}`,
"test-machine-id",
time.Now(),
),
newContainerRecordWithPorts(
"api",
"10.210.0.3",
[]string{"api.example.com:8080/http"},
"test-machine-id",
),
},
want: testCaddyfileHeader + `
# Sites generated from service ports.
http://api.example.com {
reverse_proxy 10.210.0.3:8080 {
import common_proxy
}
log
}
# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
# (not accessible via the shared admin socket /run/uncloud/caddy/admin.sock) or the latest
# generated config is invalid. Please check the service 'caddy' is running (uc inspect caddy)
# and its logs for more details (uc logs caddy).
`,
},
{
name: "no containers with x-caddy configs",
containers: []store.ContainerRecord{
newContainerRecordWithPorts(
"api",
"10.210.0.3",
[]string{"api.example.com:8080/http"},
"test-machine-id",
),
},
want: testCaddyfileHeader + `
# Sites generated from service ports.
http://api.example.com {
reverse_proxy 10.210.0.3:8080 {
import common_proxy
}
log
}
# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
# (not accessible via the shared admin socket /run/uncloud/caddy/admin.sock) or the latest
# generated config is invalid. Please check the service 'caddy' is running (uc inspect caddy)
# and its logs for more details (uc logs caddy).
`,
},
}
ctx := context.Background()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Validator is not expected to be called in these tests.
generator := NewCaddyfileGenerator("test-machine-id", nil, nil)
config, err := generator.Generate(ctx, tt.containers, false)
require.NoError(t, err)
assert.Equal(t, tt.want, normaliseGeneratedTimestamp(config), "Generated Caddyfile doesn't match")
})
}
}
func newContainerRecordWithPorts(serviceName, ip string, ports []string, machineID string) store.ContainerRecord {
portsLabel := strings.Join(ports, ",")
return store.ContainerRecord{
Container: api.ServiceContainer{
Container: api.Container{
ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
ID: serviceName + "-" + ip, // Add ID for stable sorting
State: &types.ContainerState{
State: &container.State{
Running: true,
},
Created: time.Now().UTC().Format(time.RFC3339Nano),
},
NetworkSettings: &types.NetworkSettings{
NetworkSettings: &container.NetworkSettings{
Networks: map[string]*network.EndpointSettings{
docker.NetworkName: {
IPAddress: ip,
+142
View File
@@ -0,0 +1,142 @@
package caddyconfig
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/caddyserver/caddy/v2"
)
// CaddyAdminClient is a client for interacting with the Caddy admin API over a Unix socket.
type CaddyAdminClient struct {
socketPath string
client *http.Client
}
func NewCaddyAdminClient(socketPath string) *CaddyAdminClient {
return &CaddyAdminClient{
socketPath: socketPath,
client: &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
},
},
},
}
}
// IsAvailable checks if the local Caddy instance is running and responding to admin API requests.
func (c *CaddyAdminClient) IsAvailable(ctx context.Context) bool {
// Caddy doesn't serve a /ping endpoint. It's a random endpoint we can use to check if Caddy is running.
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost/ping", nil)
if err != nil {
return false
}
resp, err := c.client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
// Any HTTP response means Caddy is running and accessible.
return true
}
// Adapt converts a Caddyfile to JSON configuration without loading or running it.
func (c *CaddyAdminClient) Adapt(ctx context.Context, caddyfile string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost/adapt", strings.NewReader(caddyfile))
if err != nil {
return "", fmt.Errorf("create adapt request: %w", err)
}
req.Header.Set("Content-Type", "text/caddyfile")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("send adapt request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode == http.StatusOK {
// Parse the response body to extract the result field.
var msg struct {
Result json.RawMessage `json:"result"`
}
if err = json.Unmarshal(body, &msg); err != nil {
return "", fmt.Errorf("parse adapt response: %w", err)
}
return string(msg.Result), nil
}
// If the response is a 400 Bad Request, try to parse the error message from it.
if resp.StatusCode == http.StatusBadRequest {
var apiError caddy.APIError
if err = json.Unmarshal(body, &apiError); err == nil {
return "", errors.New(apiError.Message)
}
}
return "", errors.New(string(body))
}
// Load loads a Caddyfile configuration into the Caddy instance running on the machine.
// Due to a Caddy bug (https://github.com/caddyserver/caddy/issues/7246), we first adapt the Caddyfile to JSON
// and then load the JSON config to get proper error handling.
func (c *CaddyAdminClient) Load(ctx context.Context, caddyfile string) error {
jsonConfig, err := c.Adapt(ctx, caddyfile)
if err != nil {
return fmt.Errorf("adapt Caddyfile to JSON config: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost/load", strings.NewReader(jsonConfig))
if err != nil {
return fmt.Errorf("create load request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("send load request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
body, _ := io.ReadAll(resp.Body)
// If the response is a 400 Bad Request, try to parse the error message from it.
if resp.StatusCode == http.StatusBadRequest {
var apiError caddy.APIError
if err = json.Unmarshal(body, &apiError); err == nil {
return fmt.Errorf("caddy responded with error: %s", apiError.Message)
}
}
return fmt.Errorf("caddy responded with error: HTTP %d: %s", resp.StatusCode, string(body))
}
// Validate checks if the provided Caddyfile can be adapted to Caddy JSON config using the running Caddy instance via
// its admin API. It doesn't guarantee that the Caddyfile is actually valid and can be loaded. For example, a tls
// directive with a missing certificate will pass the adaptation but will fail when Caddy tries to load it.
// But this is the best we can do over the admin API.
// TODO: run 'docker exec caddy-container caddy validate' to do proper validation or implement a Caddy module that
// exposes a validation endpoint.
func (c *CaddyAdminClient) Validate(ctx context.Context, caddyfile string) error {
_, err := c.Adapt(ctx, caddyfile)
return err
}
+58 -27
View File
@@ -24,8 +24,9 @@ const (
// network.
type Controller struct {
machineID string
configDir string
caddyfilePath string
generator *CaddyfileGenerator
client *CaddyAdminClient
store *store.Store
log *slog.Logger
}
@@ -39,13 +40,14 @@ func NewController(machineID, configDir, adminSock string, store *store.Store) (
}
log := slog.With("component", "caddy-controller")
validator := NewCaddyAdminValidator(adminSock)
generator := NewCaddyfileGenerator(machineID, validator, log)
client := NewCaddyAdminClient(adminSock)
generator := NewCaddyfileGenerator(machineID, client, log)
return &Controller{
machineID: machineID,
configDir: configDir,
caddyfilePath: filepath.Join(configDir, "Caddyfile"),
generator: generator,
client: client,
store: store,
log: log,
}, nil
@@ -59,11 +61,11 @@ func (c *Controller) Run(ctx context.Context) error {
c.log.Info("Subscribed to container changes in the cluster to generate Caddy configuration.")
containers = filterHealthyContainers(containers)
if err = c.generateCaddyfile(ctx, containers); err != nil {
return fmt.Errorf("generate Caddyfile configuration: %w", err)
}
c.generateAndLoadCaddyfile(ctx, containers)
// TODO: left for backward compatibility, remove later.
if err = c.generateJSONConfig(containers); err != nil {
return fmt.Errorf("generate Caddy JSON configuration: %w", err)
c.log.Error("Failed to generate Caddy JSON configuration to disk.", "err", err)
}
for {
@@ -80,15 +82,12 @@ func (c *Controller) Run(ctx context.Context) error {
continue
}
containers = filterHealthyContainers(containers)
c.generateAndLoadCaddyfile(ctx, containers)
if err = c.generateCaddyfile(ctx, containers); err != nil {
c.log.Error("Failed to generate Caddyfile configuration.", "err", err)
}
// TODO: left for backward compatibility, remove later.
if err = c.generateJSONConfig(containers); err != nil {
c.log.Error("Failed to generate Caddy JSON configuration.", "err", err)
c.log.Error("Failed to generate Caddy JSON configuration to disk.", "err", err)
}
c.log.Info("Updated Caddy configuration.", "dir", c.configDir)
case <-ctx.Done():
return nil
}
@@ -109,21 +108,53 @@ func filterHealthyContainers(containers []store.ContainerRecord) []store.Contain
return healthy
}
func (c *Controller) generateCaddyfile(ctx context.Context, containers []store.ContainerRecord) error {
caddyfile, err := c.generator.Generate(ctx, containers)
func (c *Controller) generateAndLoadCaddyfile(ctx context.Context, containers []store.ContainerRecord) {
// Check if Caddy is available before attempting to generate and load config.
caddyAvailable := c.client.IsAvailable(ctx)
caddyfile, err := c.generator.Generate(ctx, containers, caddyAvailable)
if err != nil {
return fmt.Errorf("generate Caddyfile: %w", err)
}
caddyfilePath := filepath.Join(c.configDir, "Caddyfile")
// TODO: use atomic file write to avoid partial loads on Caddy watch reload.
if err = os.WriteFile(caddyfilePath, []byte(caddyfile), 0o640); err != nil {
return fmt.Errorf("write Caddyfile to file '%s': %w", caddyfilePath, err)
}
if err = fs.Chown(caddyfilePath, "", CaddyGroup); err != nil {
return fmt.Errorf("change owner of Caddyfile '%s': %w", caddyfilePath, err)
c.log.Error("Failed to generate Caddyfile configuration.", "err", err)
return
}
if !caddyAvailable {
// Caddy is not running so the generated Caddyfile should not include user-defined configs thus must be valid.
// It's safe to write the config to disk so that when Caddy is deployed on this machine, it can pick it up.
if err = c.writeCaddyfile(caddyfile); err != nil {
c.log.Error("Failed to write Caddyfile to disk.", "err", err)
return
}
c.log.Debug("Caddy is not running on this machine, skipping configuration load.", "path", c.caddyfilePath)
return
}
// Caddy is available, try to load the config which may fail if the config is invalid. Generally, a config can
// pass the adaptation/validation step but still fail to load, for example, if it references resources that are
// not available.
if err = c.client.Load(ctx, caddyfile); err != nil {
c.log.Error("Failed to load new Caddy configuration into local Caddy instance.",
"err", err, "path", c.caddyfilePath)
// Don't write invalid config to disk.
return
}
// Config loaded successfully, now write it to disk.
if err = c.writeCaddyfile(caddyfile); err != nil {
c.log.Error("Failed to write Caddyfile to disk after successful load.", "err", err)
// Config is already loaded in Caddy, so this is not critical.
}
c.log.Info("New Caddy configuration loaded into local Caddy instance.", "path", c.caddyfilePath)
}
// writeCaddyfile writes the Caddyfile content to disk with proper permissions.
func (c *Controller) writeCaddyfile(caddyfile string) error {
if err := os.WriteFile(c.caddyfilePath, []byte(caddyfile), 0o640); err != nil {
return fmt.Errorf("write Caddyfile to file '%s': %w", c.caddyfilePath, err)
}
if err := fs.Chown(c.caddyfilePath, "", CaddyGroup); err != nil {
return fmt.Errorf("change owner of Caddyfile '%s': %w", c.caddyfilePath, err)
}
return nil
}
@@ -142,7 +173,7 @@ func (c *Controller) generateJSONConfig(containers []store.ContainerRecord) erro
if err != nil {
return fmt.Errorf("marshal Caddy configuration: %w", err)
}
configPath := filepath.Join(c.configDir, "caddy.json")
configPath := filepath.Join(filepath.Dir(c.caddyfilePath), "caddy.json")
if err = os.WriteFile(configPath, configBytes, 0o640); err != nil {
return fmt.Errorf("write Caddy configuration to file '%s': %w", configPath, err)
@@ -4,7 +4,6 @@ import (
"strings"
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/psviderski/uncloud/internal/machine/docker"
@@ -334,13 +333,13 @@ func TestGenerateJSONConfig(t *testing.T) {
func newContainer(ip string, ports ...string) api.ServiceContainer {
portsLabel := strings.Join(ports, ",")
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
State: &types.ContainerState{
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
State: &container.State{
Running: true,
},
},
NetworkSettings: &types.NetworkSettings{
NetworkSettings: &container.NetworkSettings{
Networks: map[string]*network.EndpointSettings{
docker.NetworkName: {
IPAddress: ip,
@@ -357,13 +356,13 @@ func newContainer(ip string, ports ...string) api.ServiceContainer {
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
portsLabel := strings.Join(ports, ",")
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
State: &types.ContainerState{
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
State: &container.State{
Running: true,
},
},
NetworkSettings: &types.NetworkSettings{
NetworkSettings: &container.NetworkSettings{
Networks: map[string]*network.EndpointSettings{
"other-network": {
IPAddress: "172.17.0.2",
+2 -2
View File
@@ -27,9 +27,9 @@ func (s *Server) GetConfig(ctx context.Context, _ *emptypb.Empty) (*pb.GetCaddyC
caddyfile, modifiedAt, err := s.service.Caddyfile()
if err != nil {
if os.IsNotExist(err) {
return nil, status.Errorf(codes.NotFound, err.Error())
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.GetCaddyConfigResponse{
-71
View File
@@ -1,71 +0,0 @@
package caddyconfig
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/caddyserver/caddy/v2"
)
// CaddyAdminValidator validates Caddyfile via the Caddy admin API.
type CaddyAdminValidator struct {
socketPath string
client *http.Client
}
func NewCaddyAdminValidator(socketPath string) *CaddyAdminValidator {
return &CaddyAdminValidator{
socketPath: socketPath,
client: &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
},
},
},
}
}
// Validate checks if the provided Caddyfile can be adapted to Caddy JSON config using the running Caddy instance via
// its admin API. It doesn't guarantee that the Caddyfile is actually valid and can be loaded. For example, a tls
// directive with a missing certificate will pass the adaptation but will fail when Caddy tries to load it.
// But this is the best we can do over the admin API.
// TODO: run 'docker exec caddy-container caddy validate' to do proper validation or implement a Caddy module that
// exposes a validation endpoint.
func (c *CaddyAdminValidator) Validate(ctx context.Context, caddyfile string) error {
// Bogus host is used so that http.NewRequest is happy but it doesn't matter since we're using a Unix socket.
req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost/adapt", strings.NewReader(caddyfile))
if err != nil {
return fmt.Errorf("create adapt request: %w", err)
}
req.Header.Set("Content-Type", "text/caddyfile")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("send adapt request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
body, _ := io.ReadAll(resp.Body)
// If the response is a 400 Bad Request, try to parse the error message from it.
if resp.StatusCode == http.StatusBadRequest {
var apiError caddy.APIError
if err = json.Unmarshal(body, &apiError); err == nil {
return errors.New(apiError.Message)
}
}
return errors.New(string(body))
}

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