Compare commits

..
125 Commits
Author SHA1 Message Date
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
211 changed files with 12244 additions and 3812 deletions
+6
View File
@@ -36,6 +36,12 @@ 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
+3
View File
@@ -7,6 +7,8 @@
*.dll
*.so
*.dylib
/uncloud
/uncloudd
# OS X
.DS_Store
@@ -37,3 +39,4 @@ node_modules/
# VS Code
.vscode
.devcontainer/
+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
+39 -32
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
@@ -68,29 +72,29 @@ This document provides comprehensive information about the Uncloud project for A
- **`cmd/`**: Contains main applications
- `uncloud/`: CLI tool with subcommands for machine, service, volume management
- `uncloudd/`: Daemon that runs on each machine
- `ucind/`: Development cluster management for testing
- `uncloud/`: CLI tool with subcommands for machine, service, volume management
- `uncloudd/`: Daemon that runs on each machine
- `ucind/`: Development cluster management for testing
- **`internal/`**: Internal implementation packages
- `cli/`: Command-line interface logic
- `machine/`: Machine lifecycle and state management
- `daemon/`: Daemon implementation and gRPC services
- `dns/`: Internal DNS server for service discovery
- `cli/`: Command-line interface logic
- `machine/`: Machine lifecycle and state management
- `daemon/`: Daemon implementation and gRPC services
- `dns/`: Internal DNS server for service discovery
- **`pkg/`**: Public API packages for external use
- `api/`: Core API types and definitions
- `client/`: Client libraries for interacting with Uncloud
- `api/`: Core API types and definitions
- `client/`: Client libraries for interacting with Uncloud
- **`experiment/`**: Experimental features and prototypes
- **`scripts/`**: Installation and utility scripts
- **`test/`**: Test suites and test infrastructure
- **`website/`**: Documentation website (Docusaurus)
- `landing/`: Landing page
- `docs/`: User documentation
- `landing/`: Landing page
- `docs/`: User documentation
- **`misc/`**: Design documents and guides
@@ -100,25 +104,25 @@ This document provides comprehensive information about the Uncloud project for A
```go
// Networking and orchestration
github.com/docker/docker // Docker API client
github.com/docker/compose/v2 // Docker Compose integration
golang.zx2c4.com/wireguard // WireGuard implementation
github.com/hashicorp/serf // Gossip protocol
github.com/docker/docker // Docker API client
github.com/docker/compose/v2 // Docker Compose integration
golang.zx2c4.com/wireguard // WireGuard implementation
github.com/hashicorp/serf // Gossip protocol
// State management
github.com/ipfs/go-ds-crdt // CRDT distributed storage
github.com/dgraph-io/badger/v3 // Embedded database
github.com/ipfs/go-ds-crdt // CRDT distributed storage
github.com/dgraph-io/badger/v3 // Embedded database
// Web proxy
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
// CLI and UX
github.com/spf13/cobra // CLI framework
github.com/charmbracelet/huh // Interactive forms
github.com/spf13/cobra // CLI framework
github.com/charmbracelet/huh // Interactive forms
// gRPC and networking
google.golang.org/grpc // gRPC framework
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
```
## Development Workflow
@@ -189,9 +193,9 @@ uc context use <name> # Switch context
### Testing
- Test files and locations
- Unit tests alongside source files (`*_test.go`)
- Integration tests in `test/e2e/`
- Test fixtures in `test/fixtures/`
- Unit tests alongside source files (`*_test.go`)
- Integration tests in `test/e2e/`
- Test fixtures in `test/fixtures/`
- Use table driven tests whenever possible
### Dependencies
@@ -225,8 +229,8 @@ uc context use <name> # Switch context
### Logs and Monitoring
- Systemd services (getting logs via `journalctl -u SERVICE_NAME`)
- `uncloud` -- Uncloud daemon
- `uncloud-corrosion` -- Corrosion process
- `uncloud` -- Uncloud daemon
- `uncloud-corrosion` -- Corrosion process
- Machine daemon logs
- Container logs via Docker
@@ -247,7 +251,8 @@ 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
@@ -255,4 +260,6 @@ Instructions when generating documentation:
- Use conversational language write as if you were speaking to a friend.
- Keep sentences simple, optimize for clarity and understanding.
- Place the subject before the action whenever possible. Example: prefer “The function loads data” over “Data is loaded by the function.”
- Do not use em dashes (—) or semicolons (;) in sentences. Instead, break complex sentences into simpler ones.
- Place the subject before the action whenever possible. Example: prefer "The function loads data" over "Data is loaded
by the function."
+1 -1
View File
@@ -1 +1 @@
./AI.md
./AGENTS.md
+18 -4
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>
@@ -40,7 +40,7 @@ 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.
@@ -281,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
@@ -337,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">
+1 -2
View File
@@ -49,6 +49,7 @@ to cluster machines or --push-registry to upload them to external registries.`,
return runBuild(cmd.Context(), uncli, opts)
},
GroupID: "service",
}
cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil,
@@ -74,8 +75,6 @@ to cluster machines or --push-registry to upload them to external registries.`,
"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.")
cmd.Flags().StringVarP(&opts.Context, "context", "c", "",
"Name of the cluster context. (default is the current context)")
return cmd
}
+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
}
+1 -6
View File
@@ -22,7 +22,6 @@ type deployOptions struct {
caddyfile string
image string
machines []string
context string
}
func NewDeployCommand() *cobra.Command {
@@ -46,10 +45,6 @@ func NewDeployCommand() *cobra.Command {
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
"list. (default is all machines)")
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context to deploy to. (default is the current context)",
)
return cmd
}
@@ -64,7 +59,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)
}
+79
View File
@@ -0,0 +1,79 @@
package context
import (
"fmt"
"github.com/charmbracelet/huh"
"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)
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
View File
@@ -19,6 +19,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(
NewListCommand(),
NewUseCommand(),
NewConnectionCommand(),
)
return cmd
+18 -15
View File
@@ -14,20 +14,20 @@ import (
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/spf13/cobra"
)
type deployOptions struct {
cli.BuildServicesOptions
files []string
profiles []string
services []string
noBuild bool
recreate bool
yes bool
context string
files []string
profiles []string
services []string
noBuild bool
recreate bool
skipHealth bool
yes bool
}
// NewDeployCommand creates a new command to deploy services from a Compose file.
@@ -44,6 +44,7 @@ func NewDeployCommand() *cobra.Command {
return runDeploy(cmd.Context(), uncli, opts)
},
GroupID: "service",
}
cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil,
@@ -51,8 +52,6 @@ func NewDeployCommand() *cobra.Command {
"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().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context to deploy to (default is the current context)")
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,
@@ -63,6 +62,10 @@ func NewDeployCommand() *cobra.Command {
"One or more Compose profiles to enable.")
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]")
@@ -108,7 +111,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
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)
}
@@ -151,9 +154,9 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
fmt.Println()
}
var strategy deploy.Strategy
if opts.recreate {
strategy = &deploy.RollingStrategy{ForceRecreate: true}
strategy := &deploy.RollingStrategy{
ForceRecreate: opts.recreate,
SkipHealthMonitor: opts.skipHealth,
}
composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy)
if err != nil {
@@ -201,7 +204,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
}, uncli.ProgressOut(), "Deploying services")
}
func printPlan(ctx context.Context, cli *client.Client, plan deploy.SequenceOperation) error {
func printPlan(ctx context.Context, cli *client.Client, plan operation.SequenceOperation) error {
for _, op := range plan.Operations {
svcPlan, ok := op.(*deploy.Plan)
if !ok {
+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)
}
+1 -6
View File
@@ -22,7 +22,6 @@ import (
type listOptions struct {
machines []string
nameFilter string
context string
}
func NewListCommand() *cobra.Command {
@@ -60,10 +59,6 @@ func NewListCommand() *cobra.Command {
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)")
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
@@ -82,7 +77,7 @@ type imageRow struct {
}
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) 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 -6
View File
@@ -15,7 +15,6 @@ type pushOptions struct {
image string
machines []string
platform string
context string
}
func NewPushCommand() *cobra.Command {
@@ -54,16 +53,12 @@ The image is uploaded to all cluster machines (default) or the specified machine
"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.",
)
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+98 -67
View File
@@ -5,9 +5,11 @@ import (
"errors"
"fmt"
"net/netip"
"strings"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/internal/cli"
@@ -15,8 +17,6 @@ import (
"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 {
@@ -25,8 +25,8 @@ type addOptions struct {
noInstall bool
publicIP string
sshKey string
context string
version string
yes bool
}
func NewAddCommand() *cobra.Command {
@@ -34,19 +34,33 @@ func NewAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add [USER@]HOST[:PORT]",
Short: "Add a remote machine to a cluster.",
Args: cobra.ExactArgs(1),
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)
}
remoteMachine := &cli.RemoteMachine{
User: user,
Host: host,
Port: port,
KeyPath: opts.sshKey,
User: user,
Host: host,
Port: port,
KeyPath: opts.sshKey,
UseSSHCLI: useSSHCLI,
}
return add(cmd.Context(), uncli, remoteMachine, opts)
@@ -76,10 +90,9 @@ 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().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
}
@@ -100,12 +113,12 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
}
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
Context: opts.context,
MachineName: opts.name,
PublicIP: publicIP,
RemoteMachine: remoteMachine,
SkipInstall: opts.noInstall,
Version: opts.version,
AutoConfirm: opts.yes,
})
if err != nil {
return err
@@ -117,79 +130,97 @@ 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).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
TitleStyle(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) {
return fmt.Errorf("inspect caddy service: %w", err)
if errors.Is(err, api.ErrNotFound) {
// Caddy service is not deployed.
return nil
}
} else {
caddyImage = caddySvc.Containers[0].Container.Config.Image
// Find the latest created container and use its image.
var latestCreated time.Time
for _, c := range caddySvc.Containers[1:] {
created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
if err != nil {
continue
}
if created.After(latestCreated) {
latestCreated = created
caddyImage = c.Container.Config.Image
}
return fmt.Errorf("inspect caddy service: %w", err)
}
caddyImage = caddySvc.Containers[0].Container.Config.Image
// Find the latest created container and use its image.
var latestCreated time.Time
for _, c := range caddySvc.Containers[1:] {
created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
if err != nil {
continue
}
if created.After(latestCreated) {
latestCreated = created
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.
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
if err != nil {
return fmt.Errorf("create caddy deployment: %w", err)
}
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))
plan, err := d.Plan(ctx)
if err != nil {
return err
return fmt.Errorf("plan caddy deployment: %w", err)
}
fmt.Println()
if len(plan.Operations) == 0 {
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
} else {
// Initialise a machine and container name resolver to properly format the plan output.
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, caddySvc)
if err != nil {
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
}
fmt.Println("caddy deployment plan:")
fmt.Println(plan.Format(resolver))
fmt.Println()
if !opts.yes {
confirmed, err := cli.Confirm()
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 (%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)
}
+58 -17
View File
@@ -4,7 +4,11 @@ import (
"context"
"fmt"
"net/netip"
"strings"
"time"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
"github.com/psviderski/uncloud/cmd/uncloud/dns"
@@ -27,15 +31,20 @@ type initOptions struct {
sshKey string
version string
context 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.",
Long: "Initialise a new cluster by setting up a remote machine as the first member.\n" +
"This command creates a new context in your Uncloud config to manage the cluster.",
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>
@@ -45,31 +54,45 @@ func NewInitCommand() *cobra.Command {
# 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.cluster.uncloud.run).
# 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)
}
remoteMachine = &cli.RemoteMachine{
User: user,
Host: host,
Port: port,
KeyPath: opts.sshKey,
User: user,
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(
@@ -107,15 +130,19 @@ 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", cli.DefaultContextName,
"Name of the new context to be created in the Uncloud config to manage the cluster.",
)
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 specify --connect when initialising a new cluster")
}
netPrefix, err := netip.ParsePrefix(opts.network)
if err != nil {
return fmt.Errorf("parse network CIDR: %w", err)
@@ -142,20 +169,34 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
RemoteMachine: remoteMachine,
SkipInstall: opts.noInstall,
Version: opts.version,
AutoConfirm: opts.yes,
})
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).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
TitleStyle(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 {
+3 -8
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)
}
+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
}
+17 -7
View File
@@ -22,7 +22,6 @@ import (
type removeOptions struct {
noReset bool
yes bool
context string
}
func NewRmCommand() *cobra.Command {
@@ -39,8 +38,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 +48,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
}
@@ -155,8 +152,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.GetContextOverrideOrCurrent()
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 -1
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(),
+1 -6
View File
@@ -13,7 +13,6 @@ import (
type updateOptions struct {
name string
publicIP string
context string
}
func NewUpdateCommand() *cobra.Command {
@@ -43,10 +42,6 @@ At least one flag must be specified to perform an update operation.`,
&opts.publicIP, "public-ip", "",
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)",
)
return cmd
}
@@ -57,7 +52,7 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip)")
}
client, err := uncli.ConnectCluster(ctx, opts.context)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return err
}
+48 -14
View File
@@ -6,6 +6,7 @@ import (
"net/netip"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
"github.com/psviderski/uncloud/cmd/uncloud/dns"
@@ -13,6 +14,7 @@ import (
"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"
@@ -24,6 +26,7 @@ import (
type globalOptions struct {
configPath string
connect string
context string
}
func main() {
@@ -38,6 +41,7 @@ func main() {
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
@@ -55,11 +59,12 @@ func main() {
conn = &config.MachineConnection{
SSHCLI: config.SSHDestination(dest),
}
} else {
dest := opts.connect
if strings.HasPrefix(dest, "ssh://") {
dest = dest[len("ssh://"):]
} 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),
}
@@ -67,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)
}
@@ -78,30 +83,59 @@ func main() {
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
"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], or tcp://host:port")
"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. [$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.NewExecCommand(),
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())
}
+284
View File
@@ -0,0 +1,284 @@
package main
import (
"context"
"fmt"
"sort"
"time"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-units"
"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).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
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 != "" {
client.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]interface{}{
"Id": "container1",
"Name": "test-container",
"Config": map[string]interface{}{
"Image": "test-image",
},
"State": map[string]interface{}{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
},
}
containerJSON, err := json.Marshal(containerData)
require.NoError(t, err)
serviceSpecJSON, err := json.Marshal(map[string]interface{}{})
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]interface{}{
"Id": "container1",
}
containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]interface{}{
"Id": "container2",
"Config": map[string]interface{}{
"Image": "test-image",
},
"State": map[string]interface{}{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
},
}
containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
// 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]interface{}{
"Id": "container1",
"Name": "container-1",
"Config": map[string]interface{}{
"Image": "image-1",
},
"State": map[string]interface{}{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
},
}
containerJSON1, _ := json.Marshal(containerData1)
containerData2 := map[string]interface{}{
"Id": "container2",
"Name": "container-2",
"Config": map[string]interface{}{
"Image": "image-2",
},
"State": map[string]interface{}{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
},
}
containerJSON2, _ := json.Marshal(containerData2)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
// 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]interface{}{
"Id": "container1",
"Config": map[string]interface{}{
"Image": "test-image",
},
"State": map[string]interface{}{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
},
}
containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
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]interface{}{
"Id": "container1",
"Config": map[string]interface{}{
"Image": "test-image",
},
"State": map[string]interface{}{
"Status": "running",
"StartedAt": "2023-01-01T12:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
},
"NetworkSettings": map[string]interface{}{
"Networks": map[string]interface{}{},
},
}
containerJSON, _ := json.Marshal(containerData)
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
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)
}
}
+10 -8
View File
@@ -15,18 +15,17 @@ type execCliOptions struct {
detach bool
interactive bool
noTty bool
context string
containerId string
}
var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
func NewExecCommand() *cobra.Command {
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",
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.
`,
@@ -55,11 +54,12 @@ If the service has multiple replicas and no container ID is specified, the comma
}
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", !cli.IsStdoutTerminal(),
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
@@ -69,9 +69,6 @@ If the service has multiple replicas and no container ID is specified, the comma
execCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY")
execCmd.Flags().MarkHidden("tty")
execCmd.Flags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)")
// Common flags
execCmd.Flags().StringVar(&opts.containerId, "container", "",
"ID of the container to exec into. Accepts full ID or a unique prefix "+
@@ -85,6 +82,11 @@ If the service has multiple replicas and no container ID is specified, the comma
}
func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error {
// Disable TTY allocation if not connected to a terminal
if !cli.IsStdoutTerminal() {
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 ...'
@@ -95,7 +97,7 @@ func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []
}
}
client, err := uncli.ConnectClusterWithOptions(ctx, opts.context, cli.ConnectOptions{
client, err := uncli.ConnectClusterWithOptions(ctx, cli.ConnectOptions{
ShowProgress: false,
})
if err != nil {
+28 -14
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
context 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.context, "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.context)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -63,18 +61,26 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
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 {
+336
View File
@@ -0,0 +1,336 @@
package service
import (
"context"
"errors"
"fmt"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
mapset "github.com/deckarep/golang-set/v2"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/internal/cli"
"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).
if len(serviceNames) == 0 {
project, err := compose.LoadProject(ctx, opts.files)
if err != nil {
return fmt.Errorf("load compose file(s): %w", err)
}
// 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.
machineIDsSet := mapset.NewSet[string]()
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames))
for _, serviceName := range serviceNames {
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts)
if err != nil {
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err)
}
svcStreams = append(svcStreams, ch)
machineIDs := svc.MachineIDs()
machineIDsSet.Append(machineIDs...)
}
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 = []lipgloss.Color{
lipgloss.Color("10"), // Bright green
lipgloss.Color("11"), // Bright yellow
lipgloss.Color("12"), // Bright blue
lipgloss.Color("13"), // Bright magenta
lipgloss.Color("14"), // Bright cyan
lipgloss.Color("2"), // Green
lipgloss.Color("3"), // Yellow
lipgloss.Color("4"), // Blue
lipgloss.Color("5"), // Magenta
lipgloss.Color("6"), // Cyan
}
// 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))
}
}
+13 -15
View File
@@ -4,33 +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 {
var contextName 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, contextName)
return list(cmd.Context(), uncli)
},
GroupID: groupID,
}
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, contextName string) error {
client, err := uncli.ConnectCluster(ctx, contextName)
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)
}
@@ -41,15 +39,15 @@ func list(ctx context.Context, uncli *cli.CLI, contextName 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
return strings.Compare(a.ID, b.ID)
}
serviceNames[svc.Name] = struct{}{}
}
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)
+9 -8
View File
@@ -11,31 +11,32 @@ import (
type rmOptions struct {
services []string
context 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.",
Args: cobra.MinimumNArgs(1),
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.context, "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.context)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+10 -7
View File
@@ -8,15 +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(),
NewExecCommand(""),
NewInspectCommand(""),
NewListCommand(""),
NewLogsCommand(""),
NewRmCommand(""),
NewRunCommand(""),
NewScaleCommand(""),
NewStartCommand(""),
NewStopCommand(""),
)
return cmd
}
+3 -9
View File
@@ -34,11 +34,9 @@ type runOptions struct {
replicas uint
user string
volumes []string
context string
}
func NewRunCommand() *cobra.Command {
func NewRunCommand(groupID string) *cobra.Command {
opts := runOptions{}
cmd := &cobra.Command{
@@ -56,6 +54,7 @@ func NewRunCommand() *cobra.Command {
return run(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
@@ -112,11 +111,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.context, "context", "c", "",
"Name of the cluster context to run the service in. (default is the current context)",
)
return cmd
}
@@ -126,7 +120,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
return err
}
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+3 -8
View File
@@ -15,10 +15,9 @@ import (
type scaleOptions struct {
service string
replicas uint
context string
}
func NewScaleCommand() *cobra.Command {
func NewScaleCommand(groupID string) *cobra.Command {
opts := scaleOptions{}
cmd := &cobra.Command{
Use: "scale SERVICE REPLICAS",
@@ -37,13 +36,9 @@ func NewScaleCommand() *cobra.Command {
return scale(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
@@ -57,7 +52,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
)
}
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+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
}
+1 -4
View File
@@ -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)
}
+1 -5
View File
@@ -14,7 +14,6 @@ type removeOptions struct {
force bool
machines []string
yes bool
context string
}
func NewRemoveCommand() *cobra.Command {
@@ -41,14 +40,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)
}
+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()
}
+6 -5
View File
@@ -9,9 +9,10 @@ require (
github.com/alecthomas/chroma/v2 v2.20.0
github.com/caddyserver/caddy/v2 v2.8.4
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.3.9
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b
github.com/charmbracelet/lipgloss v1.1.0
github.com/compose-spec/compose-go/v2 v2.9.0
github.com/containerd/errdefs v1.0.0
@@ -45,7 +46,7 @@ require (
github.com/muesli/termenv v0.16.0
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/psviderski/unregistry v0.3.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
@@ -65,6 +66,7 @@ require (
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 (
@@ -149,7 +151,7 @@ require (
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.17.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
@@ -414,5 +416,4 @@ require (
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
tags.cncf.io/container-device-interface v1.0.1 // indirect
)
+12 -10
View File
@@ -168,22 +168,24 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.3.9 h1:OBYdfRo6QnlIcXNmcoI2n1NNS65Nk6kI2L2FO1puS/4=
github.com/charmbracelet/bubbletea v1.3.9/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b h1:oefmPctgff7OB4cDz3Ndp+ewwrh7i+fwth70qvZCieI=
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
@@ -344,8 +346,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
@@ -1038,8 +1040,8 @@ github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDa
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/psviderski/unregistry v0.3.1 h1:2xQ3uMNMyXAASqq9+kGUgNMIFOvPSMIFGdz47uDfjhQ=
github.com/psviderski/unregistry v0.3.1/go.mod h1:DXLnzrskO5NuRlzZhpbyo1E6KpirnctUvq6WtCGkpUQ=
github.com/psviderski/unregistry v0.4.1 h1:KedQTjyj8hHbzygK9uw5YS10amMZ5cjSYHqSjUUBhoE=
github.com/psviderski/unregistry v0.4.1/go.mod h1:DXLnzrskO5NuRlzZhpbyo1E6KpirnctUvq6WtCGkpUQ=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
+1 -3
View File
@@ -40,8 +40,6 @@ type BuildServicesOptions struct {
PushRegistry bool
// Cluster-specific options (only used if PushCluster is true).
// Context is the name of the cluster context.
Context string
// Machines is a list of machine names or IDs to push the image to. If empty, images are pushed to all machines.
Machines []string
}
@@ -95,7 +93,7 @@ func (cli *CLI) BuildServices(ctx context.Context, project *composetypes.Project
// Add a line break after the build output.
fmt.Fprintln(cli.ProgressOut())
clusterClient, err := cli.ConnectCluster(ctx, opts.Context)
clusterClient, err := cli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
+112 -24
View File
@@ -28,14 +28,16 @@ const (
)
type CLI struct {
Config *config.Config
conn *config.MachineConnection
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
}
@@ -46,7 +48,8 @@ func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
}
return &CLI{
Config: cfg,
Config: cfg,
contextOverride: contextName,
}, nil
}
@@ -68,19 +71,28 @@ 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.
func (cli *CLI) GetContextOverrideOrCurrent() string {
contextName := cli.contextOverride
if contextName == "" {
contextName = cli.Config.CurrentContext
}
return contextName
}
// 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, contextName string) (*client.Client, error) {
return cli.ConnectClusterWithOptions(ctx, contextName, ConnectOptions{
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 using the given context name and options.
// 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, contextName string, opts ConnectOptions) (*client.Client, error) {
func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, opts ConnectOptions) (*client.Client, error) {
if cli.conn != nil {
return ConnectCluster(ctx, *cli.conn, opts)
}
@@ -92,6 +104,8 @@ func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, contextName strin
cli.Config.Path(),
)
}
contextName := cli.contextOverride
if contextName == "" {
// If the cluster is not specified, use the current cluster if set.
if cli.Config.CurrentContext == "" {
@@ -148,6 +162,7 @@ type InitClusterOptions struct {
RemoteMachine *RemoteMachine
SkipInstall bool
Version string
AutoConfirm bool
}
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
@@ -157,7 +172,7 @@ 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) {
@@ -183,7 +198,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
}
}
@@ -228,8 +248,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 {
@@ -265,12 +298,12 @@ func (cli *CLI) newContextName(name string) (string, error) {
}
type AddMachineOptions struct {
Context string
MachineName string
PublicIP *netip.Addr
RemoteMachine *RemoteMachine
SkipInstall bool
Version string
AutoConfirm bool
}
// AddMachine provisions a remote machine and adds it to the cluster. It returns a cluster client and a machine client.
@@ -278,11 +311,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.GetContextOverrideOrCurrent()
c, err := cli.ConnectCluster(ctx)
if err != nil {
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
}
@@ -303,6 +333,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)
@@ -319,7 +350,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
}
}
@@ -370,6 +406,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 {
@@ -384,8 +432,9 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
// Configure the remote machine to join the cluster.
joinReq := &pb.JoinClusterRequest{
Machine: addResp.Machine,
OtherMachines: otherMachines,
Machine: addResp.Machine,
OtherMachines: otherMachines,
MinStoreDbVersion: storeDBVersion,
}
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
return nil, nil, fmt.Errorf("join cluster: %w", err)
@@ -396,11 +445,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 {
@@ -420,6 +479,35 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
func provisionOrConnectRemoteMachine(
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
) (*client.Client, error) {
// 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 == "" {
+26 -22
View File
@@ -11,29 +11,29 @@ 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"`
Host string `yaml:"host,omitempty"`
PublicKey secret.Secret `yaml:"public_key,omitempty"`
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 {
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"
}
@@ -49,48 +49,52 @@ func (c *MachineConnection) Validate() error {
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, or tcp required)")
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, or tcp)")
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
}
+32
View File
@@ -53,11 +53,27 @@ func TestMachineConnection_String(t *testing.T) {
},
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 {
@@ -103,6 +119,13 @@ func TestMachineConnection_Validate(t *testing.T) {
},
wantErr: false,
},
{
name: "unix only - valid",
conn: MachineConnection{
Unix: "/path/to/socket",
},
wantErr: false,
},
{
name: "no connection method - error",
conn: MachineConnection{},
@@ -118,6 +141,15 @@ func TestMachineConnection_Validate(t *testing.T) {
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{
+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
}
+2
View File
@@ -73,6 +73,8 @@ func connectCluster(ctx context.Context, conn config.MachineConnection) (*client
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")
}
+18 -5
View File
@@ -2,6 +2,7 @@ package cli
import (
"context"
"errors"
"fmt"
"os"
"strings"
@@ -21,10 +22,11 @@ const (
)
type RemoteMachine struct {
User string
Host string
Port int
KeyPath string
User string
Host string
Port int
KeyPath string
UseSSHCLI bool // indicates ssh+cli:// should be used
}
func installCmd(user string, version string) string {
@@ -85,7 +87,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 !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 +117,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)
File diff suppressed because it is too large Load Diff
+62 -38
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,6 +16,9 @@ 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
@@ -30,8 +34,6 @@ service Docker {
rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer);
rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse);
rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
rpc ExecContainer(stream ExecContainerRequest) returns (stream ExecContainerResponse);
}
message CreateContainerRequest {
@@ -94,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.
@@ -216,39 +276,3 @@ message MachineServiceContainers {
Metadata metadata = 1;
repeated ServiceContainer containers = 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;
}
}
+73 -32
View File
@@ -26,6 +26,8 @@ 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"
@@ -37,7 +39,6 @@ const (
Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer"
Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers"
Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer"
Docker_ExecContainer_FullMethodName = "/api.Docker/ExecContainer"
)
// DockerClient is the client API for Docker service.
@@ -50,6 +51,8 @@ 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
@@ -63,7 +66,6 @@ type DockerClient interface {
InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error)
ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error)
RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error)
}
type dockerClient struct {
@@ -134,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
}
@@ -253,19 +287,6 @@ func (c *dockerClient) RemoveServiceContainer(ctx context.Context, in *RemoveCon
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[1], 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]
// DockerServer is the server API for Docker service.
// All implementations must embed UnimplementedDockerServer
// for forward compatibility.
@@ -276,6 +297,8 @@ 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
@@ -289,7 +312,6 @@ type DockerServer interface {
InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error)
ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error)
RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error
mustEmbedUnimplementedDockerServer()
}
@@ -318,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")
}
@@ -351,9 +379,6 @@ func (UnimplementedDockerServer) ListServiceContainers(context.Context, *ListSer
func (UnimplementedDockerServer) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveServiceContainer not implemented")
}
func (UnimplementedDockerServer) ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error {
return status.Errorf(codes.Unimplemented, "method ExecContainer not implemented")
}
func (UnimplementedDockerServer) mustEmbedUnimplementedDockerServer() {}
func (UnimplementedDockerServer) testEmbeddedByValue() {}
@@ -483,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 {
@@ -674,13 +717,6 @@ func _Docker_RemoveServiceContainer_Handler(srv interface{}, ctx context.Context
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]
// Docker_ServiceDesc is the grpc.ServiceDesc for Docker service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -754,17 +790,22 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "PullImage",
Handler: _Docker_PullImage_Handler,
ServerStreams: true,
},
{
StreamName: "ExecContainer",
Handler: _Docker_ExecContainer_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "ContainerLogs",
Handler: _Docker_ContainerLogs_Handler,
ServerStreams: true,
},
{
StreamName: "PullImage",
Handler: _Docker_PullImage_Handler,
ServerStreams: true,
},
},
Metadata: "internal/machine/api/pb/docker.proto",
}
+573 -173
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"
)
@@ -371,6 +372,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 +422,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 +552,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 +565,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 +578,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 +597,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 +610,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 +623,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 +640,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 +653,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 +666,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 +708,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 +721,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 +734,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 +755,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 +768,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 +781,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 +791,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 +962,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 +975,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 +988,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,113 +1012,170 @@ 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,
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,
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,
0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xc3, 0x01, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,
0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20,
0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x36, 0x0a, 0x0a, 0x63, 0x6f, 0x6e,
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x6e, 0x74,
0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
0x73, 0x1a, 0x48, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1d,
0x0a, 0x0a, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x12, 0x1c, 0x0a,
0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c,
0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x22, 0x27, 0x0a, 0x15, 0x49,
0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x02, 0x69, 0x64, 0x22, 0x40, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53,
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26,
0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x0c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x07, 0x73,
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 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,
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, 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, 0xaa, 0x01, 0x0a, 0x12, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75,
0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x07, 0x6d,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61,
0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07,
0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x37, 0x0a, 0x0e, 0x6f, 0x74, 0x68, 0x65, 0x72,
0x5f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66,
0x6f, 0x52, 0x0d, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73,
0x12, 0x2f, 0x0a, 0x14, 0x6d, 0x69, 0x6e, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x64, 0x62,
0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11,
0x6d, 0x69, 0x6e, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
0x6e, 0x22, 0x49, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68,
0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x08, 0x6d,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69,
0x6c, 0x73, 0x52, 0x08, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x22, 0x91, 0x01, 0x0a,
0x0e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12,
0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61,
0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d,
0x61, 0x63, 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, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xc3, 0x01, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76,
0x69, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18,
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x36, 0x0a, 0x0a, 0x63,
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x43, 0x6f,
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e,
0x65, 0x72, 0x73, 0x1a, 0x48, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x12,
0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01,
0x28, 0x0c, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x22, 0x27, 0x0a,
0x15, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x40, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63,
0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x26, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0xb2, 0x01, 0x0a, 0x1f, 0x49, 0x6e, 0x73,
0x70, 0x65, 0x63, 0x74, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74,
0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e,
0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x4e,
0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65,
0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b,
0x65, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72,
0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50,
0x6f, 0x72, 0x74, 0x12, 0x28, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61,
0x72, 0x64, 0x50, 0x65, 0x65, 0x72, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x22, 0x83, 0x02,
0x0a, 0x0d, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x50, 0x65, 0x65, 0x72, 0x12,
0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x1a,
0x0a, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
0x52, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x13, 0x6c, 0x61,
0x73, 0x74, 0x5f, 0x68, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x5f, 0x74, 0x69, 0x6d,
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
0x61, 0x6d, 0x70, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61,
0x6b, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76,
0x65, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72,
0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x74,
0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x74, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x74, 0x42, 0x79, 0x74,
0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x69, 0x70,
0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64,
0x49, 0x70, 0x73, 0x32, 0xe3, 0x04, 0x0a, 0x07, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12,
0x4d, 0x0a, 0x12, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69,
0x73, 0x69, 0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1f, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75,
0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40,
0x0a, 0x0b, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x17, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69,
0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x3e, 0x0a, 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12,
0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65,
0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
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,
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, 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,
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,57 +1190,72 @@ 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
(*CheckPrerequisitesResponse)(nil), // 2: api.CheckPrerequisitesResponse
(*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
(*MachineInfo)(nil), // 0: api.MachineInfo
(*NetworkConfig)(nil), // 1: api.NetworkConfig
(*CheckPrerequisitesResponse)(nil), // 2: api.CheckPrerequisitesResponse
(*InitClusterRequest)(nil), // 3: api.InitClusterRequest
(*InitClusterResponse)(nil), // 4: api.InitClusterResponse
(*JoinClusterRequest)(nil), // 5: api.JoinClusterRequest
(*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
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
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
7, // 10: api.InspectMachineResponse.machines:type_name -> api.MachineDetails
19, // 11: api.MachineDetails.metadata:type_name -> api.Metadata
0, // 12: api.MachineDetails.machine:type_name -> api.MachineInfo
15, // 13: api.Service.containers:type_name -> api.Service.Container
10, // 14: api.InspectServiceResponse.service:type_name -> api.Service
14, // 15: api.InspectWireGuardNetworkResponse.peers:type_name -> api.WireGuardPeer
20, // 16: api.WireGuardPeer.last_handshake_time:type_name -> google.protobuf.Timestamp
21, // 17: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
3, // 18: api.Machine.InitCluster:input_type -> api.InitClusterRequest
5, // 19: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest
21, // 20: api.Machine.Token:input_type -> google.protobuf.Empty
21, // 21: api.Machine.Inspect:input_type -> google.protobuf.Empty
21, // 22: api.Machine.InspectMachine:input_type -> google.protobuf.Empty
21, // 23: api.Machine.InspectWireGuardNetwork:input_type -> google.protobuf.Empty
9, // 24: api.Machine.Reset:input_type -> api.ResetRequest
11, // 25: api.Machine.InspectService:input_type -> api.InspectServiceRequest
2, // 26: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
4, // 27: api.Machine.InitCluster:output_type -> api.InitClusterResponse
21, // 28: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
8, // 29: api.Machine.Token:output_type -> api.TokenResponse
0, // 30: api.Machine.Inspect:output_type -> api.MachineInfo
6, // 31: api.Machine.InspectMachine:output_type -> api.InspectMachineResponse
13, // 32: api.Machine.InspectWireGuardNetwork:output_type -> api.InspectWireGuardNetworkResponse
21, // 33: api.Machine.Reset:output_type -> google.protobuf.Empty
12, // 34: api.Machine.InspectService:output_type -> api.InspectServiceResponse
26, // [26:35] is the sub-list for method output_type
17, // [17:26] is the sub-list for method input_type
17, // [17:17] is the sub-list for extension type_name
17, // [17:17] is the sub-list for extension extendee
0, // [0:17] is the sub-list for field type_name
}
func init() { file_internal_machine_api_pb_machine_proto_init() }
@@ -986,7 +1338,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 +1350,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 +1362,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 +1374,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 +1386,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 +1398,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 +1468,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,
},
+36
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);
@@ -58,6 +64,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 +108,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;
}
+89 -7
View File
@@ -20,13 +20,15 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
Machine_CheckPrerequisites_FullMethodName = "/api.Machine/CheckPrerequisites"
Machine_InitCluster_FullMethodName = "/api.Machine/InitCluster"
Machine_JoinCluster_FullMethodName = "/api.Machine/JoinCluster"
Machine_Token_FullMethodName = "/api.Machine/Token"
Machine_Inspect_FullMethodName = "/api.Machine/Inspect"
Machine_Reset_FullMethodName = "/api.Machine/Reset"
Machine_InspectService_FullMethodName = "/api.Machine/InspectService"
Machine_CheckPrerequisites_FullMethodName = "/api.Machine/CheckPrerequisites"
Machine_InitCluster_FullMethodName = "/api.Machine/InitCluster"
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"
)
// MachineClient is the client API for Machine service.
@@ -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,
+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()),
+8 -3
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:// {
@@ -56,7 +58,9 @@ https://{{$hostname}} {
}{{end}}
`
caddyfileUnavailabeFooter = `# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
# or the latest generated config is invalid. Please check the Caddy logs if it's running.
# (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).
`
)
@@ -120,6 +124,7 @@ func (g *CaddyfileGenerator) Generate(
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
}
+30 -13
View File
@@ -3,6 +3,7 @@ package caddyconfig
import (
"context"
"errors"
"regexp"
"strings"
"testing"
"time"
@@ -17,8 +18,17 @@ import (
"github.com/stretchr/testify/require"
)
const testCaddyfileHeader = `# 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:// {
@@ -190,7 +200,7 @@ 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")
})
}
}
@@ -216,8 +226,9 @@ func TestCaddyfileGeneratorWithCustomConfigs(t *testing.T) {
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
@@ -412,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
@@ -649,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
@@ -831,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")
})
}
}
@@ -926,7 +939,9 @@ http://api.example.com {
}
# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
# or the latest generated config is invalid. Please check the Caddy logs if it's running.
# (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).
`,
},
{
@@ -950,7 +965,9 @@ http://api.example.com {
}
# NOTE: User-defined configs for services were skipped because Caddy is not running on this machine
# or the latest generated config is invalid. Please check the Caddy logs if it's running.
# (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).
`,
},
}
@@ -964,7 +981,7 @@ http://api.example.com {
config, err := generator.Generate(ctx, tt.containers, false)
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")
})
}
}
+171 -52
View File
@@ -40,7 +40,9 @@ type clusterController struct {
corroService corroservice.Service
dockerCtrl *docker.Controller
// dockerReady is signalled when Docker is configured and ready for containers.
dockerReady chan<- struct{}
dockerReady chan<- struct{}
// clusterReady is signalled when the cluster controller has finished initializing all components.
clusterReady chan<- struct{}
caddyconfigCtrl *caddyconfig.Controller
// dnsServer is the embedded internal DNS server for the cluster listening on the machine IP.
@@ -60,6 +62,7 @@ func newClusterController(
corroService corroservice.Service,
dockerService *docker.Service,
dockerReady chan<- struct{},
clusterReady chan<- struct{},
caddyfileCtrl *caddyconfig.Controller,
dnsServer *dns.Server,
dnsResolver *dns.ClusterResolver,
@@ -81,6 +84,7 @@ func newClusterController(
corroService: corroService,
dockerCtrl: docker.NewController(state.ID, dockerService, store),
dockerReady: dockerReady,
clusterReady: clusterReady,
caddyconfigCtrl: caddyfileCtrl,
dnsServer: dnsServer,
dnsResolver: dnsResolver,
@@ -123,6 +127,21 @@ func (cc *clusterController) Run(ctx context.Context) error {
errGroup, ctx := errgroup.WithContext(ctx)
// Start the WireGuard control loop before waiting for store sync. This ensures endpoint rotation happens
// while waiting, allowing Corrosion to connect to peers.
errGroup.Go(func() error {
if err := cc.wgnet.Run(ctx); err != nil {
return fmt.Errorf("WireGuard network failed: %w", err)
}
return nil
})
// Watch for WireGuard peer endpoint changes and update the machine state accordingly.
errGroup.Go(func() error {
cc.handleEndpointChanges(ctx)
return nil
})
// Start the network API server. Assume the management IP can't be changed when the network is running.
apiAddr := net.JoinHostPort(cc.state.Network.ManagementIP.String(), strconv.Itoa(constants.MachineAPIPort))
listener, err := net.Listen("tcp", apiAddr)
@@ -137,6 +156,22 @@ func (cc *clusterController) Run(ctx context.Context) error {
return nil
})
// Wait for the store database to sync to the minimum version before starting store-dependent components.
// This prevents issues with using partially replicated data when the machine just joined the cluster,
// e.g., an empty machine list causing WireGuard peer misconfiguration.
cc.waitStoreSync(ctx)
// Check if waitStoreSync exited because the context was cancelled. Return early in that case.
if ctx.Err() != nil {
cc.stopAPIServer()
err := errGroup.Wait()
if corroErr := cc.stopCorrosion(); corroErr != nil {
err = errors.Join(err, corroErr)
}
return err
}
errGroup.Go(func() error {
slog.Info("Starting embedded DNS resolver.")
if err := cc.dnsResolver.Run(ctx); err != nil {
@@ -169,44 +204,6 @@ func (cc *clusterController) Run(ctx context.Context) error {
return nil
})
// Watch for WireGuard peer endpoint changes and update the machine state accordingly.
errGroup.Go(func() error {
for {
select {
case e, ok := <-cc.endpointChanges:
if !ok {
// The channel was closed, stop watching for changes.
cc.endpointChanges = nil
return nil
}
cc.state.mu.Lock()
for i := range cc.state.Network.Peers {
if cc.state.Network.Peers[i].PublicKey.Equal(e.PublicKey) {
cc.state.Network.Peers[i].Endpoint = &e.Endpoint
break
}
}
if err := cc.state.Save(); err != nil {
slog.Error("Failed to save machine state.", "err", err)
}
cc.state.mu.Unlock()
slog.Debug("Preserved endpoint change in the machine state.",
"public_key", e.PublicKey, "endpoint", e.Endpoint)
case <-ctx.Done():
return nil
}
}
})
errGroup.Go(func() error {
if err := cc.wgnet.Run(ctx); err != nil {
return fmt.Errorf("WireGuard network failed: %w", err)
}
return nil
})
errGroup.Go(func() error {
slog.Info("Starting caddyconfig controller.")
if err := cc.caddyconfigCtrl.Run(ctx); err != nil {
@@ -225,12 +222,13 @@ func (cc *clusterController) Run(ctx context.Context) error {
})
}
// Wait for the context to be done and stop the network API server.
// Signal that the cluster controller has finished starting all components.
close(cc.clusterReady)
slog.Info("Cluster controller finished starting all components.")
// Wait for the context to be done and stop all servers and controllers.
<-ctx.Done()
slog.Info("Stopping network API server.")
// TODO: implement timeout for graceful shutdown.
cc.server.GracefulStop()
slog.Info("Network API server stopped.")
cc.stopAPIServer()
// Stop the unregistry server with a timeout if it was started.
if cc.unregistry != nil {
@@ -248,19 +246,50 @@ func (cc *clusterController) Run(ctx context.Context) error {
// Wait for all controllers to finish.
err = errGroup.Wait()
// It's safe to stop the Corrosion service after the controllers depending on it and API server are stopped.
// Use a new context with a timeout as the current context is already canceled.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if corroErr := cc.corroService.Stop(ctx); corroErr != nil {
err = errors.Join(err, fmt.Errorf("stop corrosion service: %w", corroErr))
} else {
slog.Info("Corrosion service stopped.")
// Stop Corrosion after all controllers depending on it and API server are stopped.
if corroErr := cc.stopCorrosion(); corroErr != nil {
err = errors.Join(err, corroErr)
}
return err
}
// stopAPIServer gracefully stops the network API server with a timeout.
func (cc *clusterController) stopAPIServer() {
timeout := 10 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
stopped := make(chan struct{})
go func() {
slog.Info("Stopping network API server.")
cc.server.GracefulStop()
close(stopped)
}()
select {
case <-ctx.Done():
slog.Warn("Network API server graceful stop timed out, forcing stop.", "timeout", timeout)
cc.server.Stop()
case <-stopped:
}
slog.Info("Network API server stopped.")
}
// stopCorrosion stops the Corrosion service with a timeout.
func (cc *clusterController) stopCorrosion() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := cc.corroService.Stop(ctx); err != nil {
return fmt.Errorf("stop corrosion service: %w", err)
}
slog.Info("Corrosion service stopped.")
return nil
}
// ensureDockerNetwork ensures that the Docker network is configured and ready for containers.
func (cc *clusterController) ensureDockerNetwork(ctx context.Context) error {
if err := cc.dockerCtrl.WaitDaemonReady(ctx); err != nil {
@@ -281,6 +310,95 @@ func (cc *clusterController) ensureDockerNetwork(ctx context.Context) error {
return nil
}
// handleEndpointChanges watches for WireGuard peer endpoint changes and persists them to the machine state.
func (cc *clusterController) handleEndpointChanges(ctx context.Context) {
for {
select {
case e, ok := <-cc.endpointChanges:
if !ok {
// The channel was closed, stop watching for changes.
cc.endpointChanges = nil
return
}
cc.state.mu.Lock()
for i := range cc.state.Network.Peers {
if cc.state.Network.Peers[i].PublicKey.Equal(e.PublicKey) {
cc.state.Network.Peers[i].Endpoint = &e.Endpoint
break
}
}
if err := cc.state.Save(); err != nil {
slog.Error("Failed to save machine state.", "err", err)
}
cc.state.mu.Unlock()
slog.Debug("Preserved endpoint change in the machine state.",
"public_key", e.PublicKey, "endpoint", e.Endpoint)
case <-ctx.Done():
return
}
}
}
// waitStoreSync waits for the store database to sync to the minimum required DB version if set in the machine state.
// Blocks until synced or context is cancelled.
func (cc *clusterController) waitStoreSync(ctx context.Context) {
minVersion := cc.state.MinStoreDBVersion
if minVersion == 0 {
return
}
slog.Info("Waiting for the initial cluster store sync.", "min_version", minVersion)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
var (
lastVersion int64
lastLogTime time.Time
lastErrLogTime time.Time
)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
version, err := cc.store.DBVersion(ctx)
if err != nil {
// Log errors at most once every 5 seconds.
if time.Since(lastErrLogTime) >= 5*time.Second {
slog.Error("Failed to get the cluster store DB version, retrying.", "err", err)
lastErrLogTime = time.Now()
}
continue
}
if version >= minVersion {
slog.Info("Cluster store completed the initial sync.", "version", version, "min_version", minVersion)
// Clear MinStoreDBVersion so next restart doesn't wait for sync.
cc.state.mu.Lock()
cc.state.MinStoreDBVersion = 0
if err := cc.state.Save(); err != nil {
slog.Error("Failed to save machine state after the initial cluster store sync.", "err", err)
}
cc.state.mu.Unlock()
return
}
// Log progress only once a second.
if version != lastVersion && time.Since(lastLogTime) >= 1*time.Second {
slog.Info("Syncing cluster store.", "version", version, "min_version", minVersion)
lastLogTime = time.Now()
lastVersion = version
}
}
}
}
// syncDockerContainers watches local Docker containers and syncs them to the cluster store.
// TODO: move this to the Docker controller.
func (cc *clusterController) syncDockerContainers(ctx context.Context) error {
@@ -340,6 +458,7 @@ func (cc *clusterController) handleMachineChanges(ctx context.Context) error {
// The machine store may be empty when a machine first joins the cluster, before store synchronization
// completes. Skip configuration now and apply it when the store changes are received.
// TODO: remove this check after releasing 0.17.0 and assuming cluster machines wait for store sync on join.
if len(machines) > 0 {
slog.Info("Reconfiguring network peers with the current machines.", "machines", len(machines))
if err = cc.configurePeers(machines); err != nil {
+48 -51
View File
@@ -26,12 +26,19 @@ type Cluster struct {
corroAdmin *corrosion.AdminClient
// machineID is the ID of the current machine that is running the cluster service.
machineID string
// initialised is closed when the machine is configured as a member of a cluster.
initialised <-chan struct{}
// ready is closed when the cluster controller has finished starting all components
// and the machine is ready to serve cluster requests.
ready <-chan struct{}
}
func NewCluster(store *store.Store, corroAdmin *corrosion.AdminClient) *Cluster {
func NewCluster(store *store.Store, corroAdmin *corrosion.AdminClient, initialised, ready <-chan struct{}) *Cluster {
return &Cluster{
store: store,
corroAdmin: corroAdmin,
store: store,
corroAdmin: corroAdmin,
initialised: initialised,
ready: ready,
}
}
@@ -41,67 +48,45 @@ func (c *Cluster) UpdateMachineID(mid string) {
}
func (c *Cluster) Init(ctx context.Context, network netip.Prefix) error {
initialised, err := c.Initialised(ctx)
if err != nil {
return err
}
if initialised {
return fmt.Errorf("cluster is already initialised")
select {
case <-c.initialised:
return fmt.Errorf("cluster is already initialised on this machine")
default:
}
if err = c.store.Put(ctx, "network", network.String()); err != nil {
if err := c.store.Put(ctx, "network", network.String()); err != nil {
return fmt.Errorf("put network to store: %w", err)
}
if err = c.store.Put(ctx, "created_at", time.Now().UTC().Format(time.RFC3339)); err != nil {
if err := c.store.Put(ctx, "created_at", time.Now().UTC().Format(time.RFC3339)); err != nil {
return fmt.Errorf("put created_at to store: %w", err)
}
return nil
}
func (c *Cluster) Initialised(ctx context.Context) (bool, error) {
var createdAt string
if err := c.store.Get(ctx, "created_at", &createdAt); err != nil {
if errors.Is(err, store.ErrKeyNotFound) {
return false, nil
}
return false, status.Errorf(codes.Internal, "get created_at from store: %v", err)
// checkReady checks if the machine is ready to serve cluster requests (store synced, cluster components started).
func (c *Cluster) checkReady() error {
select {
case <-c.ready:
return nil
default:
return status.Error(codes.Unavailable, "machine is not ready to serve cluster requests")
}
return true, nil
}
func (c *Cluster) checkInitialised(ctx context.Context) error {
initialised, err := c.Initialised(ctx)
if err != nil {
return err
}
if !initialised {
return status.Error(codes.FailedPrecondition, "cluster is not initialised")
}
return nil
}
func (c *Cluster) Network(ctx context.Context) (netip.Prefix, error) {
if err := c.checkInitialised(ctx); err != nil {
return netip.Prefix{}, err
}
var net string
if err := c.store.Get(ctx, "network", &net); err != nil {
return netip.Prefix{}, status.Errorf(codes.Internal, "get network from store: %v", err)
}
prefix, err := netip.ParsePrefix(net)
if err != nil {
return netip.Prefix{}, status.Errorf(codes.Internal, "parse network prefix: %v", err)
}
return prefix, nil
}
// AddMachine adds a machine to the cluster.
func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*pb.AddMachineResponse, error) {
if err := c.checkInitialised(ctx); err != nil {
if err := c.checkReady(); err != nil {
return nil, err
}
return c.AddMachineWithoutReadyCheck(ctx, req)
}
// AddMachineWithoutReadyCheck adds a machine to the cluster without checking if the cluster is ready.
// This is used internally during cluster initialisation to add the first machine.
func (c *Cluster) AddMachineWithoutReadyCheck(
ctx context.Context, req *pb.AddMachineRequest,
) (*pb.AddMachineResponse, error) {
if req.Network == nil {
return nil, status.Error(codes.InvalidArgument, "network not set")
}
@@ -162,7 +147,7 @@ func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*p
manageIP = pb.NewIP(network.ManagementIP(req.Network.PublicKey))
}
// Allocate a subnet for the machine from the cluster network.
clusterNetwork, err := c.Network(ctx)
clusterNetwork, err := c.network(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get cluster network: %v", err)
}
@@ -198,9 +183,21 @@ func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*p
return resp, nil
}
func (c *Cluster) network(ctx context.Context) (netip.Prefix, error) {
var net string
if err := c.store.Get(ctx, "network", &net); err != nil {
return netip.Prefix{}, status.Errorf(codes.Internal, "get network from store: %v", err)
}
prefix, err := netip.ParsePrefix(net)
if err != nil {
return netip.Prefix{}, status.Errorf(codes.Internal, "parse network prefix: %v", err)
}
return prefix, nil
}
// UpdateMachine updates machine configuration in the cluster.
func (c *Cluster) UpdateMachine(ctx context.Context, req *pb.UpdateMachineRequest) (*pb.UpdateMachineResponse, error) {
if err := c.checkInitialised(ctx); err != nil {
if err := c.checkReady(); err != nil {
return nil, err
}
@@ -283,7 +280,7 @@ func (c *Cluster) UpdateMachine(ctx context.Context, req *pb.UpdateMachineReques
// ListMachines lists all machines in the cluster including their membership states.
func (c *Cluster) ListMachines(ctx context.Context, _ *emptypb.Empty) (*pb.ListMachinesResponse, error) {
if err := c.checkInitialised(ctx); err != nil {
if err := c.checkReady(); err != nil {
return nil, err
}
@@ -329,7 +326,7 @@ func (c *Cluster) ListMachines(ctx context.Context, _ *emptypb.Empty) (*pb.ListM
// RemoveMachine removes a machine from the cluster.
func (c *Cluster) RemoveMachine(ctx context.Context, req *pb.RemoveMachineRequest) (*emptypb.Empty, error) {
if err := c.checkInitialised(ctx); err != nil {
if err := c.checkReady(); err != nil {
return nil, err
}
+4 -4
View File
@@ -25,7 +25,7 @@ type uncloudDNSDomain struct {
}
func (c *Cluster) ReserveDomain(ctx context.Context, req *pb.ReserveDomainRequest) (*pb.Domain, error) {
if err := c.checkInitialised(ctx); err != nil {
if err := c.checkReady(); err != nil {
return nil, err
}
@@ -64,7 +64,7 @@ func (c *Cluster) ReserveDomain(ctx context.Context, req *pb.ReserveDomainReques
}
func (c *Cluster) GetDomain(ctx context.Context, _ *emptypb.Empty) (*pb.Domain, error) {
if err := c.checkInitialised(ctx); err != nil {
if err := c.checkReady(); err != nil {
return nil, err
}
@@ -95,7 +95,7 @@ func (c *Cluster) storedDomain(ctx context.Context) (uncloudDNSDomain, error) {
}
func (c *Cluster) ReleaseDomain(ctx context.Context, _ *emptypb.Empty) (*pb.Domain, error) {
if err := c.checkInitialised(ctx); err != nil {
if err := c.checkReady(); err != nil {
return nil, err
}
@@ -115,7 +115,7 @@ func (c *Cluster) ReleaseDomain(ctx context.Context, _ *emptypb.Empty) (*pb.Doma
func (c *Cluster) CreateDomainRecords(
ctx context.Context, req *pb.CreateDomainRecordsRequest,
) (*pb.CreateDomainRecordsResponse, error) {
if err := c.checkInitialised(ctx); err != nil {
if err := c.checkReady(); err != nil {
return nil, err
}
+4 -4
View File
@@ -201,10 +201,10 @@ func handleClientOutputStream(ctx context.Context, stream pb.Docker_ExecContaine
// ExecContainer executes a command in a running container with bidirectional streaming.
// TODO: This can be merged with pkg/client as it's an unnecessary logic split.
func (c *Client) ExecContainer(ctx context.Context, opts ExecConfig) (exitCode int, err error) {
// TODO: We need to handle Ctrl-C and other signals here to forward them to the container process.
// Right now, Ctrl-C will just terminate the client process, which is not ideal.
// We should catch the signal, send it to the container process, and only exit
// when the container process exits.
// Note: In non-interactive mode (without TTY), signals like SIGTERM/SIGINT will terminate the client
// process without being forwarded to the remote container process. We could catch and forward these
// signals (useful for long-running commands), but for example "kubectl exec" doesn't do this either (as of November 2025),
// so we keep it simple for now.
slog.Debug("starting ExecContainer", "containerID", opts.ContainerID, "options", opts.Options)
+157 -10
View File
@@ -33,6 +33,7 @@ import (
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat"
"github.com/docker/go-units"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
@@ -47,6 +48,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
@@ -565,6 +567,22 @@ func (s *Server) CreateServiceContainer(
if spec.Mode == "" {
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
}
if hc := spec.Container.Healthcheck; hc != nil {
if hc.Disable {
config.Healthcheck = &container.HealthConfig{
Test: []string{"NONE"},
}
} else {
config.Healthcheck = &container.HealthConfig{
Test: hc.Test,
Interval: hc.Interval,
Timeout: hc.Timeout,
StartPeriod: hc.StartPeriod,
StartInterval: hc.StartInterval,
Retries: int(hc.Retries),
}
}
}
// TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB.
var err error
@@ -604,6 +622,8 @@ func (s *Server) CreateServiceContainer(
}
}
hostConfig := &container.HostConfig{
CapAdd: spec.Container.CapAdd,
CapDrop: spec.Container.CapDrop,
Binds: spec.Container.Volumes,
Init: spec.Container.Init,
Mounts: mounts,
@@ -613,13 +633,16 @@ func (s *Server) CreateServiceContainer(
NanoCPUs: spec.Container.Resources.CPU,
Memory: spec.Container.Resources.Memory,
MemoryReservation: spec.Container.Resources.MemoryReservation,
Devices: toDockerDevices(spec.Container.Resources.Devices),
DeviceRequests: spec.Container.Resources.DeviceReservations,
Ulimits: toDockerUlimits(spec.Container.Resources.Ulimits),
},
// Restart service containers if they exit or a machine restarts unless they are explicitly stopped.
// For one-off containers and batch jobs we plan to use a different service type/mode.
RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyUnlessStopped,
},
Sysctls: spec.Container.Sysctls,
}
// Configure the container to use the internal DNS server if it's available.
@@ -800,14 +823,17 @@ func (s *Server) injectConfigs(ctx context.Context, containerID string, configs
}
// copyContentToContainer copies content directly to a file in the container using Docker's CopyToContainer API.
// It will create any intermediate directories in the target path that don't exist.
func (s *Server) copyContentToContainer(ctx context.Context, containerID string, content []byte, targetPath string, uid *uint64, gid *uint64, fileMode os.FileMode) error {
// Create a tar archive containing the file
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
// Create tar header
// Trim leading slash(es) to avoid double slashes in the tar path
tarPath := strings.TrimPrefix(targetPath, "/")
// Create tar header with full path
header := &tar.Header{
Name: filepath.Base(targetPath),
Name: tarPath,
Size: int64(len(content)),
Mode: int64(fileMode),
ModTime: time.Now(),
@@ -833,16 +859,12 @@ func (s *Server) copyContentToContainer(ctx context.Context, containerID string,
return fmt.Errorf("close tar writer: %w", err)
}
// Copy the tar archive to the container
targetDir := filepath.Dir(targetPath)
if targetDir == "." {
targetDir = "/"
}
// Always extract to root. The tar archive contains the full path, so tar will
// automatically create any intermediate directories that don't exist in the container.
if err := s.client.CopyToContainer(
ctx,
containerID,
targetDir,
"/",
&buf,
container.CopyToContainerOptions{CopyUIDGID: true},
); err != nil {
@@ -874,6 +896,40 @@ func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
return dockerOpts
}
func toDockerUlimits(ulimits map[string]api.Ulimit) []*units.Ulimit {
if len(ulimits) == 0 {
return nil
}
dockerUlimits := make([]*units.Ulimit, 0, len(ulimits))
for name, u := range ulimits {
dockerUlimits = append(dockerUlimits, &units.Ulimit{
Name: name,
Soft: u.Soft,
Hard: u.Hard,
})
}
return dockerUlimits
}
func toDockerDevices(devices []api.DeviceMapping) []container.DeviceMapping {
if len(devices) == 0 {
return nil
}
dockerDevices := make([]container.DeviceMapping, 0, len(devices))
for _, d := range devices {
dockerDevices = append(dockerDevices, container.DeviceMapping{
PathOnHost: d.HostPath,
PathInContainer: d.ContainerPath,
CgroupPermissions: d.CgroupPermissions,
})
}
return dockerDevices
}
// verifyDockerVolumesExist checks if the Docker named volumes referenced in the mounts exist on the machine.
func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mount) error {
for _, m := range mounts {
@@ -1015,6 +1071,97 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
return resp, nil
}
// logsHeartbeatInterval is the interval at which heartbeat entries are sent when there are no logs to stream.
const logsHeartbeatInterval = 200 * time.Millisecond
// ContainerLogs streams logs from a container.
func (s *Server) ContainerLogs(
req *pb.ContainerLogsRequest, stream grpc.ServerStreamingServer[pb.ContainerLogEntry],
) error {
// Stream context is cancelled when the client has disconnected or the stream has ended.
ctx := stream.Context()
opts := ContainerLogsOptions{
ContainerID: req.ContainerId,
Follow: req.Follow,
Tail: int(req.Tail),
Since: req.Since,
Until: req.Until,
}
logsCh, err := s.service.ContainerLogs(ctx, opts)
if err != nil {
if errdefs.IsNotFound(err) {
return status.Error(codes.NotFound, err.Error())
}
return status.Errorf(codes.Internal, "get container logs: %v", err)
}
log := slog.With("container_id", req.ContainerId, "stream_id", fmt.Sprintf("%p", stream)[2:])
log.Debug("Starting container logs streaming.",
"follow", req.Follow, "tail", req.Tail, "since", req.Since, "until", req.Until)
// Heartbeats are needed only when following logs to let the client know when there are no new log entries
// to allow it to advance the watermark of last received log timestamp.
var heartbeatCh <-chan time.Time
if req.Follow {
heartbeatTicker := time.NewTicker(logsHeartbeatInterval)
defer heartbeatTicker.Stop()
heartbeatCh = heartbeatTicker.C
}
started := time.Now()
lastSent := time.Time{}
for {
select {
case entry, ok := <-logsCh:
if !ok {
// Channel closed, no more log entries.
return nil
}
if entry.Err != nil {
return status.Error(codes.Internal, entry.Err.Error())
}
pbEntry := &pb.ContainerLogEntry{
Stream: api.LogStreamTypeToProto(entry.Stream),
Timestamp: timestamppb.New(entry.Timestamp),
Message: entry.Message,
}
if err = stream.Send(pbEntry); err != nil {
return status.Errorf(codes.Internal, "send log entry: %v", err)
}
lastSent = entry.Timestamp
case now := <-heartbeatCh:
// Only send heartbeat if no log entries have been sent since the last heartbeat interval or
// if no log entries have been sent at all for at least a heartbeat interval since starting.
if now.Sub(lastSent) < logsHeartbeatInterval ||
(lastSent.IsZero() && now.Sub(started) < logsHeartbeatInterval) {
continue
}
// Use the timestamp one heartbeat in the past to be conservative. This reduces the chance of sending
// a timestamp that is greater than a log entry currently being parsed but not yet sent, which would
// cause the client to incorrectly believe it has received all logs up to that point.
heartbeat := &pb.ContainerLogEntry{
Stream: pb.ContainerLogEntry_HEARTBEAT,
Timestamp: timestamppb.New(now.Add(-logsHeartbeatInterval)),
}
if err = stream.Send(heartbeat); err != nil {
return status.Errorf(codes.Internal, "send log stream heartbeat: %v", err)
}
lastSent = heartbeat.Timestamp.AsTime()
log.Debug("Sent log stream heartbeat.", "timestamp", lastSent)
case <-ctx.Done():
return status.Error(codes.Canceled, ctx.Err().Error())
}
}
}
// receiveExecConfig receives and validates the initial exec configuration from the stream.
func (s *Server) receiveExecConfig(stream pb.Docker_ExecContainerServer) (*pb.ExecConfig, api.ExecOptions, error) {
req, err := stream.Recv()
+105
View File
@@ -1,18 +1,22 @@
package docker
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/jmoiron/sqlx"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/grpc/codes"
@@ -150,3 +154,104 @@ func (s *Service) ListImages(ctx context.Context, opts image.ListOptions) (Image
return imagesResp, nil
}
// ContainerLogsOptions specifies parameters for ContainerLogs.
type ContainerLogsOptions struct {
ContainerID string
Follow bool
Tail int
Since string
Until string
}
// ContainerLogs streams logs from a container and returns demultiplexed entries via a channel.
// The channel is closed when streaming completes or context is cancelled.
func (s *Service) ContainerLogs(ctx context.Context, opts ContainerLogsOptions) (<-chan api.ContainerLogEntry, error) {
dockerOpts := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: opts.Follow,
Tail: strconv.FormatInt(int64(opts.Tail), 10),
Since: opts.Since,
Until: opts.Until,
Timestamps: true,
}
reader, err := s.Client.ContainerLogs(ctx, opts.ContainerID, dockerOpts)
if err != nil {
return nil, err
}
outCh := make(chan api.ContainerLogEntry)
stdoutWriter := &logsChannelWriter{ctx: ctx, ch: outCh, isStderr: false}
stderrWriter := &logsChannelWriter{ctx: ctx, ch: outCh, isStderr: true}
// Wrap the context in a cancellable one to unblock the second goroutine below when StdCopy completes.
ctx, cancel := context.WithCancel(ctx)
// Run StdCopy in a goroutine to be able to handle context cancellation.
go func() {
defer close(outCh)
defer cancel()
// StdCopy is blocking and will return when the reader is closed in another goroutine below or on error.
if _, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, reader); err != nil {
// Send error as the last entry.
select {
case outCh <- api.ContainerLogEntry{Err: fmt.Errorf("demultiplex container logs: %w", err)}:
case <-ctx.Done():
}
}
}()
// Close the reader when the context is done to cancel StdCopy if it's still running.
go func() {
<-ctx.Done()
reader.Close()
}()
return outCh, nil
}
// logsChannelWriter is a writer for stdcopy.StdCopy that sends demultiplexed container logs to a channel.
type logsChannelWriter struct {
ctx context.Context
ch chan<- api.ContainerLogEntry
isStderr bool
}
func (w *logsChannelWriter) Write(data []byte) (n int, err error) {
// Parse timestamp and message from the demultiplexed Docker log payload if the data looks like it contains one.
// Format: 2025-01-01T00:00:00.000000000Z message
timestamp := time.Time{}
message := data
if len(data) > 30 && data[4] == '-' && data[7] == '-' && data[10] == 'T' {
timestampPart, messagePart, found := bytes.Cut(data, []byte(" "))
if found {
timestamp, err = time.Parse(time.RFC3339Nano, string(timestampPart))
if err != nil {
timestamp = time.Time{}
}
message = messagePart
}
}
entry := api.ContainerLogEntry{
Timestamp: timestamp,
// Clone is required because message is a slice into data, which stdcopy.StdCopy may reuse
// after Write returns but before the entry is consumed from the channel.
Message: bytes.Clone(message),
}
if w.isStderr {
entry.Stream = api.LogStreamStderr
} else {
entry.Stream = api.LogStreamStdout
}
select {
case w.ch <- entry:
return len(data), nil
case <-w.ctx.Done():
return 0, w.ctx.Err()
}
}
+97 -8
View File
@@ -33,10 +33,12 @@ import (
"github.com/psviderski/unregistry"
"github.com/siderolabs/grpc-proxy/proxy"
"golang.org/x/sync/errgroup"
"golang.zx2c4.com/wireguard/wgctrl"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
@@ -174,10 +176,13 @@ type Machine struct {
state *State
// started is closed when the machine is ready to serve requests on the local API server.
started chan struct{}
// initialised is signalled when the machine is configured as a member of a cluster.
// initialised is closed when the machine is configured as a member of a cluster.
initialised chan struct{}
// networkReady is signalled when the Docker network is configured and ready for containers.
// networkReady is closed when the Docker network is configured and ready for containers.
networkReady chan struct{}
// clusterReady is closed when the cluster controller has finished starting all components
// and the machine is ready to serve cluster requests.
clusterReady chan struct{}
// resetting is true when the machine is being reset.
resetting bool
// stop cancels the Run method context to stop the machine gracefully.
@@ -246,7 +251,10 @@ func NewMachine(config *Config) (*Machine, error) {
if err != nil {
return nil, fmt.Errorf("create corrosion admin client: %w", err)
}
c := cluster.NewCluster(corroStore, corroAdmin)
initialised := make(chan struct{})
clusterReady := make(chan struct{})
c := cluster.NewCluster(corroStore, corroAdmin, initialised, clusterReady)
// Init dependencies for a gRPC Docker server that proxies requests to the local Docker daemon.
dbFilePath := filepath.Join(config.DataDir, DBFileName)
@@ -269,8 +277,9 @@ func NewMachine(config *Config) (*Machine, error) {
config: *config,
state: state,
started: make(chan struct{}),
initialised: make(chan struct{}, 1),
initialised: initialised,
networkReady: make(chan struct{}),
clusterReady: clusterReady,
store: corroStore,
cluster: c,
dockerService: dockerService,
@@ -294,7 +303,7 @@ func NewMachine(config *Config) (*Machine, error) {
m.localMachineServer = newGRPCServer(m, c, m.dockerServer, caddyServer)
if m.Initialised() {
m.initialised <- struct{}{}
close(m.initialised)
}
return m, nil
@@ -475,6 +484,7 @@ func (m *Machine) Run(ctx context.Context) error {
m.config.CorrosionService,
m.dockerService,
m.networkReady,
m.clusterReady,
caddyconfigCtrl,
dnsServer,
dnsResolver,
@@ -722,7 +732,7 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
addReq.PublicIp = pb.NewIP(publicIP)
}
addResp, err := m.cluster.AddMachine(ctx, addReq)
addResp, err := m.cluster.AddMachineWithoutReadyCheck(ctx, addReq)
if err != nil {
return nil, status.Errorf(codes.Internal, "add machine to cluster: %v", err)
}
@@ -749,7 +759,7 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
}
slog.Info("Cluster initialised with machine.", "id", m.state.ID, "machine", m.state.Name)
// Signal that the machine is initialised as a member of a cluster.
m.initialised <- struct{}{}
close(m.initialised)
resp := &pb.InitClusterResponse{
Machine: addResp.Machine,
@@ -792,6 +802,7 @@ func (m *Machine) JoinCluster(_ context.Context, req *pb.JoinClusterRequest) (*e
PrivateKey: m.state.Network.PrivateKey,
PublicKey: m.state.Network.PublicKey,
}
m.state.MinStoreDBVersion = req.MinStoreDbVersion
// Build a peers config from other cluster machines.
m.state.Network.Peers = make([]network.PeerConfig, 0, len(req.OtherMachines))
@@ -830,7 +841,7 @@ func (m *Machine) JoinCluster(_ context.Context, req *pb.JoinClusterRequest) (*e
"peers", len(m.state.Network.Peers),
)
// Signal that the machine is initialised as a member of a cluster.
m.initialised <- struct{}{}
close(m.initialised)
return &emptypb.Empty{}, nil
}
@@ -864,6 +875,7 @@ func (m *Machine) Token(_ context.Context, _ *emptypb.Empty) (*pb.TokenResponse,
return &pb.TokenResponse{Token: tokenStr}, nil
}
// Deprecated: use InspectMachine instead.
func (m *Machine) Inspect(_ context.Context, _ *emptypb.Empty) (*pb.MachineInfo, error) {
return &pb.MachineInfo{
Id: m.state.ID,
@@ -876,6 +888,31 @@ func (m *Machine) Inspect(_ context.Context, _ *emptypb.Empty) (*pb.MachineInfo,
}, nil
}
func (m *Machine) InspectMachine(ctx context.Context, _ *emptypb.Empty) (*pb.InspectMachineResponse, error) {
dbVersion, err := m.store.DBVersion(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get database version of the cluster store: %v", err)
}
return &pb.InspectMachineResponse{
Machines: []*pb.MachineDetails{
{
// Metadata is injected by the gRPC proxy.
Machine: &pb.MachineInfo{
Id: m.state.ID,
Name: m.state.Name,
Network: &pb.NetworkConfig{
Subnet: pb.NewIPPrefix(m.state.Network.Subnet),
ManagementIp: pb.NewIP(m.state.Network.ManagementIP),
PublicKey: m.state.Network.PublicKey,
},
},
StoreDbVersion: dbVersion,
},
},
}, nil
}
// IsNetworkReady returns true if the Docker network is ready for containers.
func (m *Machine) IsNetworkReady() bool {
if !m.Initialised() {
@@ -909,6 +946,58 @@ func (m *Machine) WaitForNetworkReady(ctx context.Context) error {
}
}
// InspectWireGuardNetwork retrieves the current WireGuard network configuration and peer status.
func (m *Machine) InspectWireGuardNetwork(
_ context.Context, _ *emptypb.Empty,
) (*pb.InspectWireGuardNetworkResponse, error) {
deviceName := network.WireGuardInterfaceName
wg, err := wgctrl.New()
if err != nil {
return nil, fmt.Errorf("create WireGuard client: %w", err)
}
defer wg.Close()
dev, err := wg.Device(deviceName)
if err != nil {
return nil, fmt.Errorf("get WireGuard device '%s': %w", deviceName, err)
}
peers := make([]*pb.WireGuardPeer, len(dev.Peers))
for i, p := range dev.Peers {
var lastHandshake *timestamppb.Timestamp
if !p.LastHandshakeTime.IsZero() {
lastHandshake = timestamppb.New(p.LastHandshakeTime)
}
allowedIPs := make([]string, len(p.AllowedIPs))
for j, ip := range p.AllowedIPs {
allowedIPs[j] = ip.String()
}
var endpoint string
if p.Endpoint != nil {
endpoint = p.Endpoint.String()
}
peers[i] = &pb.WireGuardPeer{
PublicKey: p.PublicKey[:],
Endpoint: endpoint,
LastHandshakeTime: lastHandshake,
ReceiveBytes: p.ReceiveBytes,
TransmitBytes: p.TransmitBytes,
AllowedIps: allowedIPs,
}
}
return &pb.InspectWireGuardNetworkResponse{
InterfaceName: dev.Name,
PublicKey: dev.PublicKey[:],
ListenPort: int32(dev.ListenPort),
Peers: peers,
}, nil
}
// Reset restores the machine to a clean state, scheduling a graceful shutdown and removing all cluster-related
// configuration and resource. The uncloud daemon will restart the machine if managed by systemd.
func (m *Machine) Reset(_ context.Context, _ *pb.ResetRequest) (*emptypb.Empty, error) {
+37 -2
View File
@@ -24,6 +24,9 @@ type State struct {
Name string
// Network specifies the network configuration for this machine.
Network *network.Config
// MinStoreDBVersion is the latest database version of one of the existing cluster machines at the time this machine
// joined the cluster. The machine should sync to at least this version before starting any cluster operations.
MinStoreDBVersion int64 `json:",omitempty"`
// path is the file path config is read from and saved to.
path string
@@ -66,7 +69,7 @@ func (c *State) Encode() ([]byte, error) {
return data, nil
}
// Save writes the state data to the file at the given path.
// Save atomically writes the state data to the file at the configured path.
func (c *State) Save() error {
if c.path == "" {
return fmt.Errorf("state path not set")
@@ -80,5 +83,37 @@ func (c *State) Save() error {
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0o600)
// Write to a temporary file and rename for atomic save. CreateTemp creates a file with mode 0o600.
tmpFile, err := os.CreateTemp(dir, ".machine.json.*")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmpFile.Name()
// Clean up temp file on error.
defer func() {
if err != nil {
os.Remove(tmpPath)
}
}()
if _, err = tmpFile.Write(data); err != nil {
tmpFile.Close()
return fmt.Errorf("write temp file: %w", err)
}
// Without fsync, the data may only be in the OS buffer cache. If the system crashes before the OS flushes
// it to disk, the file could be empty or corrupted even after rename.
if err = tmpFile.Sync(); err != nil {
tmpFile.Close()
return fmt.Errorf("sync temp file: %w", err)
}
if err = tmpFile.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err = os.Rename(tmpPath, c.path); err != nil {
return fmt.Errorf("rename temp file: %w", err)
}
return nil
}
+35 -6
View File
@@ -82,7 +82,7 @@ func (s *Store) CreateOrUpdateContainer(ctx context.Context, ctr api.ServiceCont
// ListContainers returns a list of container records from the store database that match the given options.
func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]ContainerRecord, error) {
q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers").
q := sq.Select("id", "container", "machine_id", "sync_status", "updated_at").From("containers").
Where(sq.Eq{"sync_status": SyncStatusSynced})
if len(opts.MachineIDs) > 0 {
@@ -112,14 +112,23 @@ func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]Contain
defer rows.Close()
var containers []ContainerRecord
var cJSON, machineID, syncStatus, updatedAtStr string
var id, cJSON, machineID, syncStatus, updatedAtStr string
var updatedAt time.Time
skipped := 0
for rows.Next() {
if err = rows.Scan(&cJSON, &machineID, &syncStatus, &updatedAtStr); err != nil {
if err = rows.Scan(&id, &cJSON, &machineID, &syncStatus, &updatedAtStr); err != nil {
return nil, fmt.Errorf("scan container record: %w", err)
}
// Skip containers with empty JSON data. This can happen during partial replication
// when cr-sqlite has created the row but the container column hasn't been synced yet.
if cJSON == "" || cJSON == "{}" {
slog.Debug("Skipping container with empty data in the store (partial replication?).", "id", id)
skipped++
continue
}
var c api.ServiceContainer
if err = json.Unmarshal([]byte(cJSON), &c); err != nil {
return nil, fmt.Errorf("unmarshal container: %w", err)
@@ -135,6 +144,11 @@ func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]Contain
})
}
if skipped > 0 {
slog.Warn("Listing containers from the store skipped empty records (possibly due to partial replication).",
"skipped", skipped, "valid", len(containers))
}
return containers, nil
}
@@ -166,7 +180,7 @@ func (s *Store) DeleteContainers(ctx context.Context, opts DeleteOptions) error
// receive any values, it just signals when a container(s) has been added, updated, or deleted in the database.
func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-chan struct{}, error) {
// TODO: figure out whether we need sync_status at all (not used at the moment).
q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers").
q := sq.Select("id", "container", "machine_id", "sync_status", "updated_at").From("containers").
Where(sq.Eq{"sync_status": SyncStatusSynced})
query, args, err := q.ToSql()
if err != nil {
@@ -179,15 +193,24 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
}
var containers []ContainerRecord
var cJSON, updatedAtStr string
var id, cJSON, updatedAtStr string
skipped := 0
rows := sub.Rows()
for rows.Next() {
var cr ContainerRecord
if err = rows.Scan(&cJSON, &cr.MachineID, &cr.SyncStatus, &updatedAtStr); err != nil {
if err = rows.Scan(&id, &cJSON, &cr.MachineID, &cr.SyncStatus, &updatedAtStr); err != nil {
return nil, nil, err
}
// Skip containers with empty JSON data. This can happen during partial replication
// when cr-sqlite has created the row but the container column hasn't been synced yet.
if cJSON == "" || cJSON == "{}" {
slog.Debug("Skipping container with empty data in the store (partial replication?).", "id", id)
skipped++
continue
}
if err = json.Unmarshal([]byte(cJSON), &cr.Container); err != nil {
return nil, nil, fmt.Errorf("unmarshal container: %w", err)
}
@@ -196,6 +219,12 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
}
containers = append(containers, cr)
}
if skipped > 0 {
slog.Warn("Container subscription skipped empty records in the store (possibly due to partial replication).",
"skipped", skipped, "valid", len(containers))
}
events, err := sub.Changes()
if err != nil {
return nil, nil, fmt.Errorf("get subscription changes: %w", err)
+58 -6
View File
@@ -56,6 +56,25 @@ func (s *Store) Delete(ctx context.Context, key string) error {
return err
}
// DBVersion returns the current cr-sqlite database version (Lamport timestamp).
func (s *Store) DBVersion(ctx context.Context) (int64, error) {
rows, err := s.corro.QueryContext(ctx, "SELECT crsql_db_version()")
if err != nil {
return 0, fmt.Errorf("query crsql_db_version(): %w", err)
}
defer rows.Close()
if !rows.Next() {
return 0, fmt.Errorf("no result from crsql_db_version()")
}
var version int64
if err = rows.Scan(&version); err != nil {
return 0, fmt.Errorf("scan db version: %w", err)
}
return version, nil
}
func (s *Store) CreateMachine(ctx context.Context, m *pb.MachineInfo) error {
mJSON, err := protojson.Marshal(m)
if err != nil {
@@ -116,19 +135,29 @@ func (s *Store) GetMachine(ctx context.Context, machineID string) (*pb.MachineIn
}
func (s *Store) ListMachines(ctx context.Context) ([]*pb.MachineInfo, error) {
rows, err := s.corro.QueryContext(ctx, "SELECT info FROM machines ORDER BY name")
rows, err := s.corro.QueryContext(ctx, "SELECT id, info FROM machines ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var machines []*pb.MachineInfo
skipped := 0
for rows.Next() {
var mJSON string
if err = rows.Scan(&mJSON); err != nil {
var id, mJSON string
if err = rows.Scan(&id, &mJSON); err != nil {
return nil, err
}
// Skip machines with empty JSON data. This can happen during partial replication
// when cr-sqlite has created the row but the info column hasn't been synced yet.
if mJSON == "" || mJSON == "{}" {
slog.Debug("Skipping machine with empty data in the store (partial replication?).", "id", id)
skipped++
continue
}
protojsonParser := protojson.UnmarshalOptions{DiscardUnknown: true}
var m pb.MachineInfo
if err = protojsonParser.Unmarshal([]byte(mJSON), &m); err != nil {
@@ -141,6 +170,12 @@ func (s *Store) ListMachines(ctx context.Context) ([]*pb.MachineInfo, error) {
}
machines = append(machines, &m)
}
if skipped > 0 {
slog.Warn("Listing machines from the store skipped empty records (possibly due to partial replication).",
"skipped", skipped, "valid", len(machines))
}
return machines, nil
}
@@ -186,24 +221,41 @@ func (s *Store) DeleteMachine(ctx context.Context, id string) error {
// SubscribeMachines returns a list of machines and a channel that signals changes to the list. The channel doesn't
// receive any values, it just signals when a machine has been added, updated, or deleted in the database.
func (s *Store) SubscribeMachines(ctx context.Context) ([]*pb.MachineInfo, <-chan struct{}, error) {
sub, err := s.corro.SubscribeContext(ctx, "SELECT info FROM machines ORDER BY name", nil, false)
sub, err := s.corro.SubscribeContext(ctx, "SELECT id, info FROM machines ORDER BY name", nil, false)
if err != nil {
return nil, nil, err
}
rows := sub.Rows()
var machines []*pb.MachineInfo
skipped := 0
for rows.Next() {
var mJSON string
if err = rows.Scan(&mJSON); err != nil {
var id, mJSON string
if err = rows.Scan(&id, &mJSON); err != nil {
return nil, nil, err
}
// Skip machines with empty JSON data. This can happen during partial replication
// when cr-sqlite has created the row but the info column hasn't been synced yet.
if mJSON == "" || mJSON == "{}" {
slog.Debug("Skipping machine with empty data in the store (partial replication?).", "id", id)
skipped++
continue
}
var m pb.MachineInfo
if err = protojson.Unmarshal([]byte(mJSON), &m); err != nil {
return nil, nil, fmt.Errorf("unmarshal machine info: %w", err)
}
machines = append(machines, &m)
}
if skipped > 0 {
slog.Warn("Machine subscription skipped empty records in the store (possibly due to partial replication).",
"skipped", skipped, "valid", len(machines))
}
events, err := sub.Changes()
if err != nil {
return nil, nil, fmt.Errorf("get subscription changes: %w", err)
+11
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net"
"os"
osuser "os/user"
"strconv"
"time"
@@ -13,7 +14,17 @@ import (
)
func Connect(user, host string, port int, sshKeyPath string) (*ssh.Client, error) {
// Use the current OS user if no user is specified to be make it consistent with ssh CLI behavior.
if user == "" {
if u, err := osuser.Current(); err == nil {
user = u.Username
}
}
if port == 0 {
port = 22
}
addr := net.JoinHostPort(host, strconv.Itoa(port))
// Try to connect using SSH agent only.
agentAuth, agentClose, agentErr := sshAgentAuth()
if agentErr == nil {
+77
View File
@@ -0,0 +1,77 @@
package sshexec
import (
"context"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
)
type SSHCLIRemote struct {
user string
host string
port int
keyPath string
}
func NewSSHCLIRemote(user, host string, port int, keyPath string) *SSHCLIRemote {
return &SSHCLIRemote{
user: user,
host: host,
port: port,
keyPath: keyPath,
}
}
// TODO: Refactor and reuse this with buildDialArgs and buildSSHArgs from
// SSHCLI Connector.
func (r *SSHCLIRemote) buildSSHArgs() []string {
args := []string{"-o", "ConnectTimeout=5"}
if r.port != 0 {
args = append(args, "-p", strconv.Itoa(r.port))
}
if r.keyPath != "" {
args = append(args, "-i", r.keyPath)
}
dst := r.host
if r.user != "" {
dst = fmt.Sprintf("%s@%s", r.user, dst)
}
args = append(args, dst)
return args
}
func (r *SSHCLIRemote) Run(ctx context.Context, cmd string) (string, error) {
args := r.buildSSHArgs()
args = append(args, cmd)
execCmd := exec.CommandContext(ctx, "ssh", args...)
output, err := execCmd.CombinedOutput()
if err != nil {
return strings.TrimSpace(string(output)),
fmt.Errorf("run command on remote host: %w: %s", err, string(output))
}
return strings.TrimSpace(string(output)), nil
}
func (r *SSHCLIRemote) Stream(ctx context.Context, cmd string, stdout, stderr io.Writer) error {
args := r.buildSSHArgs()
args = append(args, cmd)
execCmd := exec.CommandContext(ctx, "ssh", args...)
execCmd.Stdout = stdout
execCmd.Stderr = stderr
return execCmd.Run()
}
// no-op as there is no persistent connection.
func (r *SSHCLIRemote) Close() error {
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package sshexec
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
user string
host string
port int
keyPath string
expected []string
}{
{
name: "host only",
host: "example.com",
expected: []string{"-o", "ConnectTimeout=5", "example.com"},
},
{
name: "with user",
user: "root",
host: "example.com",
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"},
},
{
name: "with port",
host: "example.com",
port: 2222,
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "example.com"},
},
{
name: "with key",
host: "example.com",
keyPath: "/path/to/key",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "example.com"},
},
{
name: "user and port",
user: "ubuntu",
host: "192.168.1.10",
port: 2222,
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "ubuntu@192.168.1.10"},
},
{
name: "user and key",
user: "root",
host: "example.com",
keyPath: "/path/to/key",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com"},
},
{
name: "port and key",
host: "example.com",
port: 22,
keyPath: "~/.ssh/id_rsa",
expected: []string{"-o", "ConnectTimeout=5", "-p", "22", "-i", "~/.ssh/id_rsa", "example.com"},
},
{
name: "all options",
user: "admin",
host: "server.local",
port: 2222,
keyPath: "~/.ssh/id_rsa",
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "~/.ssh/id_rsa", "admin@server.local"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
remote := &SSHCLIRemote{
user: tt.user,
host: tt.host,
port: tt.port,
keyPath: tt.keyPath,
}
result := remote.buildSSHArgs()
assert.Equal(t, tt.expected, result)
})
}
}
+46 -16
View File
@@ -119,17 +119,16 @@ func (p *Provisioner) CreateCluster(ctx context.Context, name string, opts Creat
func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error {
// Init a new cluster on the first machine.
initMachine := machines[0]
if err := WaitMachineReady(ctx, initMachine, 30*time.Second); err != nil {
return fmt.Errorf("wait for machine %q to be ready: %w", initMachine.Name, err)
}
initClient, err := initMachine.Connect(ctx)
if err != nil {
return fmt.Errorf("create machine client over TCP '%s': %w", initMachine.APIAddress, err)
}
defer initClient.Close()
if err := initClient.WaitMachineReady(ctx, 30*time.Second); err != nil {
return fmt.Errorf("wait for machine %q to be ready: %w", initMachine.Name, err)
}
req := &pb.InitClusterRequest{
MachineName: initMachine.Name,
Network: pb.NewIPPrefix(cluster.DefaultNetwork),
@@ -138,14 +137,22 @@ func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error
if err != nil {
return fmt.Errorf("init cluster: %w", err)
}
fmt.Printf("Cluster %q initialised with machine %q\n", initMachine.ClusterName, initResp.Machine.Name)
fmt.Printf("Waiting for cluster to be ready...")
if err = initClient.WaitClusterReady(ctx, 30*time.Second); err != nil {
return fmt.Errorf("wait for cluster to be ready: %w", err)
}
fmt.Println(" done.")
// Get the current store DB version from the init machine to pass to the join requests.
inspectResp, err := initClient.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
if err != nil {
return fmt.Errorf("inspect init machine: %w", err)
}
// Join the rest of the machines to the cluster.
for _, m := range machines[1:] {
if err = WaitMachineReady(ctx, m, 30*time.Second); err != nil {
return fmt.Errorf("wait for machine %q to be ready: %w", m.Name, err)
}
cli, err := m.Connect(ctx)
if err != nil {
return fmt.Errorf("create machine client over TCP '%s': %w", m.APIAddress, err)
@@ -153,6 +160,10 @@ func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error
//goland:noinspection GoDeferInLoop
defer cli.Close()
if err := cli.WaitMachineReady(ctx, 30*time.Second); err != nil {
return fmt.Errorf("wait for machine %q to be ready: %w", m.Name, err)
}
tokenResp, err := cli.Token(ctx, &emptypb.Empty{})
if err != nil {
return fmt.Errorf("get machine token: %w", err)
@@ -181,8 +192,9 @@ func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error
// Configure the machine to join the cluster.
joinReq := &pb.JoinClusterRequest{
Machine: addResp.Machine,
OtherMachines: []*pb.MachineInfo{initResp.Machine},
Machine: addResp.Machine,
OtherMachines: []*pb.MachineInfo{initResp.Machine},
MinStoreDbVersion: inspectResp.Machines[0].StoreDbVersion,
}
if _, err = cli.JoinCluster(ctx, joinReq); err != nil {
return fmt.Errorf("join cluster: %w", err)
@@ -256,16 +268,16 @@ func (p *Provisioner) InspectCluster(ctx context.Context, name string) (Cluster,
// WaitClusterReady waits for all machines in the cluster to be ready and UP.
func (p *Provisioner) WaitClusterReady(ctx context.Context, c Cluster, timeout time.Duration) error {
firstMachine := c.Machines[0]
if err := WaitMachineReady(ctx, firstMachine, timeout); err != nil {
return fmt.Errorf("wait for machine '%s' to be ready: %w", firstMachine.Name, err)
}
cli, err := firstMachine.Connect(ctx)
if err != nil {
return fmt.Errorf("connect to machine over TCP '%s': %w", firstMachine.APIAddress, err)
}
defer cli.Close()
if err = cli.WaitClusterReady(ctx, timeout); err != nil {
return fmt.Errorf("wait for cluster to be ready: %w", err)
}
boff := backoff.WithContext(backoff.NewExponentialBackOff(
backoff.WithInitialInterval(100*time.Millisecond),
backoff.WithMaxInterval(1*time.Second),
@@ -289,7 +301,25 @@ func (p *Provisioner) WaitClusterReady(ctx context.Context, c Cluster, timeout t
}
return nil
}
return backoff.Retry(checkMachinesUp, boff)
if err = backoff.Retry(checkMachinesUp, boff); err != nil {
return err
}
// Wait for each machine to sync the cluster store and be ready to serve cluster requests.
for _, m := range c.Machines[1:] {
mcli, err := m.Connect(ctx)
if err != nil {
return fmt.Errorf("connect to machine over TCP '%s': %w", m.APIAddress, err)
}
//goland:noinspection GoDeferInLoop
defer mcli.Close()
if err = mcli.WaitClusterReady(ctx, timeout); err != nil {
return fmt.Errorf("wait for cluster to be ready on machine '%s': %w", m.Name, err)
}
}
return nil
}
func (p *Provisioner) RemoveCluster(ctx context.Context, name string) error {
-25
View File
@@ -9,7 +9,6 @@ import (
"net/netip"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
@@ -17,7 +16,6 @@ import (
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/connector"
"google.golang.org/protobuf/types/known/emptypb"
)
const (
@@ -194,26 +192,3 @@ func randomMachineName() (string, error) {
}
return "machine-" + suffix, nil
}
// WaitMachineReady waits for the machine API to respond.
func WaitMachineReady(ctx context.Context, m Machine, timeout time.Duration) error {
cli, err := m.Connect(ctx)
if err != nil {
return fmt.Errorf("connect to machine over TCP '%s': %w", m.APIAddress, err)
}
defer cli.Close()
boff := backoff.WithContext(backoff.NewExponentialBackOff(
backoff.WithInitialInterval(100*time.Millisecond),
backoff.WithMaxInterval(10*time.Second),
backoff.WithMaxElapsedTime(timeout),
), ctx)
inspect := func() error {
if _, err := cli.Inspect(ctx, &emptypb.Empty{}); err != nil {
return fmt.Errorf("inspect machine: %w", err)
}
return nil
}
return backoff.Retry(inspect, boff)
}
+1 -1
View File
@@ -22,7 +22,7 @@ Uncloud stores its configuration in `~/.config/uncloud/config.yaml`. If you wish
Begin by initialising the first node in your cluster with `uc machine init [USER@HOST:PORT]`. If you do not have a need for Caddy reverse proxy, you may disable this feature with `--no-caddy`. If you want to avoid using uncloud's managed DNS service, add the `--no-dns` flag.
This command will idempotently install Docker, uncloudd, uncloud-corrosion. If Caddy is enabled, it will set up a reverse proxy. If Uncloud DNS is enabled, it will create a DNS A record for the machine's public IP address under `*.[CLUSTER ID].cluster.uncloud.run`.
This command will idempotently install Docker, uncloudd, uncloud-corrosion. If Caddy is enabled, it will set up a reverse proxy. If Uncloud DNS is enabled, it will create a DNS A record for the machine's public IP address under `*.[CLUSTER ID].uncld.dev`.
If you wish to uninstall Uncloud and its components, run `uncloud-uninstall`.
+10 -41
View File
@@ -2,13 +2,10 @@ package api
import (
"context"
"fmt"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"google.golang.org/grpc/metadata"
)
type Client interface {
@@ -24,11 +21,14 @@ type ContainerClient interface {
CreateContainer(
ctx context.Context, serviceID string, spec ServiceSpec, machineID string,
) (container.CreateResponse, error)
ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error)
InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error)
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error)
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
WaitContainerHealthy(
ctx context.Context, serviceNameOrID, containerNameOrID string, opts WaitContainerHealthyOptions,
) error
}
type DNSClient interface {
@@ -51,6 +51,8 @@ type ServiceClient interface {
RunService(ctx context.Context, spec ServiceSpec) (RunServiceResponse, error)
InspectService(ctx context.Context, id string) (Service, error)
RemoveService(ctx context.Context, id string) error
StopService(ctx context.Context, id string, opts container.StopOptions) error
StartService(ctx context.Context, id string) error
}
type VolumeClient interface {
@@ -59,40 +61,7 @@ type VolumeClient interface {
RemoveVolume(ctx context.Context, machineNameOrID, volumeName string, force bool) error
}
// ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
// If namesOrIDs is nil, all machines are included.
func ProxyMachinesContext(
ctx context.Context, cli MachineClient, namesOrIDs []string,
) (context.Context, MachineMembersList, error) {
// TODO: move the machine IP resolution to the proxy router to allow setting machine names and IDs in the metadata.
machines, err := cli.ListMachines(ctx, nil)
if err != nil {
return nil, nil, fmt.Errorf("list machines: %w", err)
}
var proxiedMachines MachineMembersList
var notFound []string
for _, nameOrID := range namesOrIDs {
if m := machines.FindByNameOrID(nameOrID); m != nil {
proxiedMachines = append(proxiedMachines, m)
} else {
notFound = append(notFound, nameOrID)
}
}
if len(notFound) > 0 {
return nil, nil, fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
}
if len(namesOrIDs) == 0 {
proxiedMachines = machines
}
md := metadata.New(nil)
for _, m := range proxiedMachines {
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
md.Append("machines", machineIP.String())
}
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
// AsPtr returns a pointer to the given value. Useful for optional fields in API structs.
func AsPtr[T any](v T) *T {
return &v
}
+71
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
)
@@ -98,6 +99,76 @@ func (c *ConfigMount) Validate() error {
return nil
}
// Compare compares this ConfigMount with another.
// Returns:
//
// -1 if c < other
// 0 if c == other
// +1 if c > other
func (c *ConfigMount) Compare(other *ConfigMount) int {
if c.ConfigName != other.ConfigName {
if c.ConfigName < other.ConfigName {
return -1
}
return 1
}
if c.ContainerPath != other.ContainerPath {
if c.ContainerPath < other.ContainerPath {
return -1
}
return 1
}
if c.Uid != other.Uid {
if c.Uid < other.Uid {
return -1
}
return 1
}
if c.Gid != other.Gid {
if c.Gid < other.Gid {
return -1
}
return 1
}
// Compare Mode (handle nil cases)
if c.Mode == nil && other.Mode != nil {
return -1
}
if c.Mode != nil && other.Mode == nil {
return 1
}
if c.Mode != nil && other.Mode != nil {
if *c.Mode < *other.Mode {
return -1
}
if *c.Mode > *other.Mode {
return 1
}
}
return 0
}
// Equals compares two ConfigMount instances for equality.
func (c *ConfigMount) Equals(other *ConfigMount) bool {
return c.Compare(other) == 0
}
func (c *ConfigMount) Clone() ConfigMount {
clone := *c
if c.Mode != nil {
mode := *c.Mode
clone.Mode = &mode
}
return clone
}
// sortConfigMounts sorts a slice of ConfigMount instances.
func sortConfigMounts(mounts []ConfigMount) {
sort.Slice(mounts, func(i, j int) bool {
return mounts[i].Compare(&mounts[j]) < 0
})
}
// ValidateConfigsAndMounts takes config specs and config mounts and validates that all mounts refer to existing specs
func ValidateConfigsAndMounts(configs []ConfigSpec, mounts []ConfigMount) error {
configMap := make(map[string]struct{})
+77
View File
@@ -3,7 +3,9 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/netip"
"os"
"strings"
"time"
@@ -41,6 +43,19 @@ func (c *Container) CreatedTime() time.Time {
return c.created
}
// HasHealthcheck returns true if the container has a health check configured.
func (c *Container) HasHealthcheck() bool {
hc := c.Config.Healthcheck
if hc == nil {
return false
}
if len(hc.Test) > 0 && hc.Test[0] == "NONE" {
return false
}
return true
}
// Healthy determines if the container is running and healthy.
// A running container with no health check configured is considered healthy.
func (c *Container) Healthy() bool {
@@ -135,6 +150,10 @@ func (c *Container) UnmarshalJSON(data []byte) error {
}
*c = Container(temp)
if c.ContainerJSONBase == nil {
return fmt.Errorf("container data is missing mandatory base fields: %s", data)
}
c.Name = strings.TrimPrefix(c.Name, "/")
return nil
@@ -240,3 +259,61 @@ func (c *ServiceContainer) UnmarshalJSON(data []byte) error {
return nil
}
// DefaultHealthMonitorPeriod is the default duration (5 seconds) to wait before checking that the container is still
// running and not restarting. Can be overridden with the UNCLOUD_HEALTH_MONITOR_PERIOD environment variable
// (e.g. "10s" or "0").
var DefaultHealthMonitorPeriod = defaultHealthMonitorPeriod()
func defaultHealthMonitorPeriod() time.Duration {
if v, ok := os.LookupEnv("UNCLOUD_HEALTH_MONITOR_PERIOD"); ok {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return 5 * time.Second
}
// WaitContainerHealthyOptions configures the behaviour of WaitContainerHealthy.
type WaitContainerHealthyOptions struct {
// MonitorPeriod is how long to wait before checking that the container is still running and not restarting.
// Containers with a health check that become healthy before the period ends succeed early.
// nil means use the default DefaultHealthMonitorPeriod.
// Zero skips the monitoring and checks the container's health immediately after starting.
MonitorPeriod *time.Duration
}
// ExecOptions contains configuration for executing a command in a container.
type ExecOptions struct {
// Command is the command to run in the container.
Command []string
// AttachStdin attaches the stdin stream to the exec session.
AttachStdin bool
// AttachStdout attaches the stdout stream to the exec session.
AttachStdout bool
// AttachStderr attaches the stderr stream to the exec session.
AttachStderr bool
// Tty allocates a pseudo-TTY for the exec session.
Tty bool
// Detach runs the command in the background without attaching to streams.
Detach bool
//// Not yet implemented fields
// User specifies the user to run the command as.
User string
// Privileged runs the command in privileged mode.
Privileged bool
// WorkingDir sets the working directory for the command.
WorkingDir string
// Env sets environment variables for the command.
Env []string
// Client-side only fields (not serialized, not sent to server)
// Stdin is the input stream. Defaults to os.Stdin if nil.
Stdin io.Reader `json:"-"`
// Stdout is the output stream. Defaults to os.Stdout if nil.
Stdout io.Writer `json:"-"`
// Stderr is the error stream. Defaults to os.Stderr if nil.
Stderr io.Writer `json:"-"`
}
-37
View File
@@ -1,37 +0,0 @@
package api
import "io"
// ExecOptions contains configuration for executing a command in a container.
type ExecOptions struct {
// Command is the command to run in the container.
Command []string
// AttachStdin attaches the stdin stream to the exec session.
AttachStdin bool
// AttachStdout attaches the stdout stream to the exec session.
AttachStdout bool
// AttachStderr attaches the stderr stream to the exec session.
AttachStderr bool
// Tty allocates a pseudo-TTY for the exec session.
Tty bool
// Detach runs the command in the background without attaching to streams.
Detach bool
//// Not yet implemented fields
// User specifies the user to run the command as.
User string
// Privileged runs the command in privileged mode.
Privileged bool
// WorkingDir sets the working directory for the command.
WorkingDir string
// Env sets environment variables for the command.
Env []string
// Client-side only fields (not serialized, not sent to server)
// Stdin is the input stream. Defaults to os.Stdin if nil.
Stdin io.Reader `json:"-"`
// Stdout is the output stream. Defaults to os.Stdout if nil.
Stdout io.Writer `json:"-"`
// Stderr is the error stream. Defaults to os.Stderr if nil.
Stderr io.Writer `json:"-"`
}
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"errors"
"time"
"github.com/psviderski/uncloud/internal/machine/api/pb"
)
const (
LogStreamUnknown LogStreamType = iota
LogStreamStdout
LogStreamStderr
// LogStreamHeartbeat represents a heartbeat log entry with a timestamp indicating that
// there are no older logs than this timestamp.
LogStreamHeartbeat
)
type LogStreamType int
// LogStreamTypeFromProto converts a protobuf ContainerLogEntry.StreamType to the internal LogStreamType.
func LogStreamTypeFromProto(s pb.ContainerLogEntry_StreamType) LogStreamType {
switch s {
case pb.ContainerLogEntry_STDOUT:
return LogStreamStdout
case pb.ContainerLogEntry_STDERR:
return LogStreamStderr
case pb.ContainerLogEntry_HEARTBEAT:
return LogStreamHeartbeat
default:
return LogStreamUnknown
}
}
// LogStreamTypeToProto converts LogStreamType to protobuf ContainerLogEntry.StreamType.
func LogStreamTypeToProto(s LogStreamType) pb.ContainerLogEntry_StreamType {
switch s {
case LogStreamStdout:
return pb.ContainerLogEntry_STDOUT
case LogStreamStderr:
return pb.ContainerLogEntry_STDERR
case LogStreamHeartbeat:
return pb.ContainerLogEntry_HEARTBEAT
default:
return pb.ContainerLogEntry_UNKNOWN
}
}
// ServiceLogsOptions specifies parameters for ServiceLogs.
type ServiceLogsOptions struct {
Follow bool
Tail int
Since string
Until string
// Machines filters logs to only include containers running on the specified machines (names or IDs).
// If empty, logs from all machines are included.
Machines []string
}
// ServiceLogEntry represents a single log entry from a service container.
type ServiceLogEntry struct {
// Metadata may not be set if an error occurred (Err is not nil).
Metadata ServiceLogEntryMetadata
ContainerLogEntry
}
// ServiceLogEntryMetadata contains metadata about the source of a log entry.
type ServiceLogEntryMetadata struct {
ServiceID string
ServiceName string
ContainerID string
MachineID string
MachineName string
}
// ContainerLogEntry represents a single log entry from a container.
type ContainerLogEntry struct {
Stream LogStreamType
Timestamp time.Time
Message []byte
// Err indicates that an error occurred while streaming logs from a container.
// Other fields are not set if Err is not nil.
Err error
}
// ErrLogStreamStalled indicates that a log stream stopped sending data and may be unresponsive.
var ErrLogStreamStalled = errors.New("log stream stopped responding")
+20 -1
View File
@@ -17,6 +17,25 @@ type ContainerResources struct {
// MemoryReservation is the minimum amount of memory (in bytes) the container needs to run efficiently.
// TODO: implement a placement constraint that checks available memory on machines.
MemoryReservation int64
// Device reservations/requests for access to things like GPUs
// Devices provides direct access to host devices.
Devices []DeviceMapping
// DeviceReservations requests for access to things like GPUs.
DeviceReservations []container.DeviceRequest
// Ulimits defines the resource limits for the container.
Ulimits map[string]Ulimit
}
// DeviceMapping represents a device mapping between host and container.
type DeviceMapping struct {
// HostPath is the path to the device on the host.
HostPath string
// ContainerPath is the path to the device in the container.
ContainerPath string
// CgroupPermissions is the cgroup permissions for the device (e.g., "rwm").
CgroupPermissions string
}
type Ulimit struct {
Soft int64
Hard int64
}
+111 -2
View File
@@ -7,7 +7,9 @@ import (
"regexp"
"slices"
"strings"
"time"
mapset "github.com/deckarep/golang-set/v2"
"github.com/distribution/reference"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
@@ -18,6 +20,13 @@ const (
ServiceModeReplicated = "replicated"
ServiceModeGlobal = "global"
// UpdateOrderStartFirst starts the new container before stopping the old one.
// This minimizes downtime but briefly runs both containers.
UpdateOrderStartFirst = "start-first"
// UpdateOrderStopFirst stops the old container before starting the new one.
// This prevents data corruption for stateful services but causes brief downtime.
UpdateOrderStopFirst = "stop-first"
// PullPolicyAlways means the image is always pulled from the registry.
PullPolicyAlways = "always"
// PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where
@@ -58,6 +67,8 @@ type ServiceSpec struct {
Ports []PortSpec
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
Replicas uint `json:",omitempty"`
// UpdateConfig configures how the service is updated during a deployment.
UpdateConfig UpdateConfig `json:",omitempty"`
// Volumes is list of data volumes that can be mounted into the container.
Volumes []VolumeSpec
// Configs is list of configuration objects that can be mounted into the container.
@@ -224,13 +235,20 @@ func (s *ServiceSpec) Clone() ServiceSpec {
// ContainerSpec defines the desired state of a container in a service.
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
type ContainerSpec struct {
// Specifies which additional capabilities should be added for the container.
CapAdd []string
// Specifies which capabilities should be dropped from the container.
CapDrop []string
// Command overrides the default CMD of the image to be executed when running a container.
Command []string
// Entrypoint overrides the default ENTRYPOINT of the image.
Entrypoint []string
// Env defines the environment variables to set inside the container.
Env EnvVars
Image string
Env EnvVars
// Healthcheck defines the health check configuration for the container or overrides the health check options
// defined in the image. If nil, the image's default health check is used.
Healthcheck *HealthcheckSpec `json:",omitempty"`
Image string
// Run a custom init inside the container. If nil, use the daemon's configured settings.
Init *bool
// LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default.
@@ -242,6 +260,8 @@ type ContainerSpec struct {
PullPolicy string
// Resource allocation for the container.
Resources ContainerResources
// Namespaced kernel parameters to be set in container
Sysctls map[string]string
// User overrides the default user of the image used to run the container. Format: user|UID[:group|GID].
User string
// VolumeMounts specifies how volumes are mounted into the container filesystem.
@@ -289,18 +309,32 @@ func (s *ContainerSpec) Equals(spec ContainerSpec) bool {
orig := s.SetDefaults()
spec = spec.SetDefaults()
// Volumes
slices.Sort(orig.Volumes)
slices.Sort(spec.Volumes)
// Volume mounts
sortVolumeMounts(orig.VolumeMounts)
sortVolumeMounts(spec.VolumeMounts)
// Config mounts
sortConfigMounts(orig.ConfigMounts)
sortConfigMounts(spec.ConfigMounts)
return cmp.Equal(orig, spec, cmpopts.EquateEmpty())
}
func (s *ContainerSpec) Clone() ContainerSpec {
spec := *s
if s.CapAdd != nil {
spec.CapAdd = make([]string, len(s.CapAdd))
copy(spec.CapAdd, s.CapAdd)
}
if s.CapDrop != nil {
spec.CapDrop = make([]string, len(s.CapDrop))
copy(spec.CapDrop, s.CapDrop)
}
if s.Command != nil {
spec.Command = make([]string, len(s.Command))
copy(spec.Command, s.Command)
@@ -309,6 +343,17 @@ func (s *ContainerSpec) Clone() ContainerSpec {
spec.Entrypoint = make([]string, len(s.Entrypoint))
copy(spec.Entrypoint, s.Entrypoint)
}
if s.Env != nil {
spec.Env = make(EnvVars, len(s.Env))
for k, v := range s.Env {
spec.Env[k] = v
}
}
if s.Healthcheck != nil {
hc := *s.Healthcheck
hc.Test = slices.Clone(s.Healthcheck.Test)
spec.Healthcheck = &hc
}
if s.LogDriver != nil {
logDriver := *s.LogDriver
if s.LogDriver.Options != nil {
@@ -324,6 +369,27 @@ func (s *ContainerSpec) Clone() ContainerSpec {
spec.VolumeMounts = make([]VolumeMount, len(s.VolumeMounts))
copy(spec.VolumeMounts, s.VolumeMounts)
}
if s.ConfigMounts != nil {
spec.ConfigMounts = make([]ConfigMount, len(s.ConfigMounts))
for i, cm := range s.ConfigMounts {
spec.ConfigMounts[i] = cm.Clone()
}
}
if s.Sysctls != nil {
spec.Sysctls = make(map[string]string, len(s.Sysctls))
for k, v := range s.Sysctls {
spec.Sysctls[k] = v
}
}
if s.Resources.Ulimits != nil {
spec.Resources.Ulimits = maps.Clone(s.Resources.Ulimits)
}
if s.Resources.Devices != nil {
spec.Resources.Devices = slices.Clone(s.Resources.Devices)
}
if s.Resources.DeviceReservations != nil {
spec.Resources.DeviceReservations = slices.Clone(s.Resources.DeviceReservations)
}
return spec
}
@@ -342,6 +408,26 @@ func (e EnvVars) ToSlice() []string {
return env
}
// HealthcheckSpec defines the health check configuration for a container.
type HealthcheckSpec struct {
// Test is the command used to check health.
// Formats: ["CMD", args...], ["CMD-SHELL", "command"], or ["NONE"] to disable.
Test []string `json:",omitempty"`
// Interval is the time between health checks.
// Zero means to inherit the value from the image or use the Docker default (30s) if not defined in the image.
Interval time.Duration `json:",omitempty"`
// Timeout is how long to wait before considering the checck to have hung.
Timeout time.Duration `json:",omitempty"`
// StartPeriod is the initialisation time for a container before the retries start to count down.
StartPeriod time.Duration `json:",omitempty"`
// StartInterval is the time between health checks during the start period.
StartInterval time.Duration `json:",omitempty"`
// Retries is the number of consecutive failures needed to consider a container unhealthy.
Retries uint `json:",omitempty"`
// Disable disables the health check defined in the image. true is equivalent to setting Test to ["NONE"].
Disable bool `json:",omitempty"`
}
type LogDriver struct {
// Name of the logging driver to use.
Name string
@@ -349,6 +435,19 @@ type LogDriver struct {
Options map[string]string
}
// UpdateConfig configures how a service is updated during a deployment.
type UpdateConfig struct {
// Order specifies the order of operations during an update.
// Valid values are "start-first" (default for stateless services) and "stop-first" (default for services with
// volumes). Empty value means the strategy will determine the order based on service characteristics.
Order string `json:",omitempty"`
// MonitorPeriod is how long to wait after starting a container before checking that it's still running
// and not restarting. Containers with a health check that become healthy before the period ends succeed early.
// nil means use the default api.DefaultHealthMonitorPeriod.
// Zero skips the monitoring and checks the container's health immediately after starting.
MonitorPeriod *time.Duration `json:",omitempty"`
}
type RunServiceResponse struct {
ID string
Name string
@@ -366,6 +465,16 @@ type MachineServiceContainer struct {
Container ServiceContainer
}
// MachineIDs returns a list of unique machine IDs where the service containers are running.
func (s *Service) MachineIDs() []string {
ids := mapset.NewSet[string]()
for _, mc := range s.Containers {
ids.Add(mc.MachineID)
}
return ids.ToSlice()
}
// Images returns a sorted list of unique images used by the service containers.
func (s *Service) Images() []string {
images := make(map[string]struct{})
+107
View File
@@ -1,11 +1,20 @@
package api
import (
"os"
"testing"
"github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// boolPtr is a convenience function to create a pointer to a uint64 value
// TODO: Make this a generic function that works for any type
func boolPtr(b bool) *bool {
return &b
}
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
tests := []struct {
name string
@@ -197,3 +206,101 @@ func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
})
}
}
func TestContainerSpec_Clone(t *testing.T) {
mode := os.FileMode(0o644)
original := ContainerSpec{
CapAdd: []string{"NET_ADMIN"},
CapDrop: []string{"ALL"},
Command: []string{"sh", "-c", "echo hello"},
Entrypoint: []string{"/bin/bash"},
Env: EnvVars{
"FOO": "bar",
"BAZ": "qux",
},
Image: "nginx:latest",
Init: boolPtr(true),
LogDriver: &LogDriver{
Name: "json-file",
Options: map[string]string{
"max-size": "10m",
},
},
Privileged: true,
PullPolicy: PullPolicyAlways,
Resources: ContainerResources{
CPU: 1234,
Memory: 2345,
MemoryReservation: 3456,
Devices: []DeviceMapping{
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
},
DeviceReservations: []container.DeviceRequest{
{Count: 1, Capabilities: [][]string{{"gpu"}}, Driver: "nvidia"},
},
},
Sysctls: map[string]string{
"net.ipv4.ip_forward": "1",
},
User: "1000:1000",
Volumes: []string{"/data", "/config"},
VolumeMounts: []VolumeMount{
{VolumeName: "data", ContainerPath: "/data"},
},
ConfigMounts: []ConfigMount{
{ConfigName: "app-config", ContainerPath: "/etc/config", Mode: &mode},
},
}
cloned := original.Clone()
// Check ContainerSpec equality
assert.True(t, original.Equals(cloned))
// Verify deep copy by modifying the original
stringModified := "modified"
original.CapAdd[0] = stringModified
original.CapDrop[0] = stringModified
original.Command[0] = stringModified
original.Entrypoint[0] = stringModified
original.Env["FOO"] = stringModified
original.LogDriver.Options["max-size"] = stringModified
original.Volumes[0] = stringModified
original.VolumeMounts[0].ContainerPath = stringModified
original.ConfigMounts[0].ContainerPath = stringModified
*original.ConfigMounts[0].Mode = 0o755 // Modify the Mode pointer value
original.Sysctls["net.ipv4.ip_forward"] = stringModified
original.Resources.Devices[0].HostPath = stringModified
original.Resources.DeviceReservations[0].Count = 2
original.Resources.DeviceReservations[0].Driver = stringModified
assert.False(t, original.Equals(cloned))
// Assert cloned values are unchanged
assert.Equal(t, "NET_ADMIN", cloned.CapAdd[0])
assert.Equal(t, "ALL", cloned.CapDrop[0])
assert.Equal(t, "sh", cloned.Command[0])
assert.Equal(t, "/bin/bash", cloned.Entrypoint[0])
assert.Equal(t, "bar", cloned.Env["FOO"])
assert.Equal(t, "qux", cloned.Env["BAZ"])
assert.Equal(t, "nginx:latest", cloned.Image)
assert.NotNil(t, cloned.Init)
assert.Equal(t, true, *cloned.Init)
assert.NotNil(t, cloned.LogDriver)
assert.Equal(t, "json-file", cloned.LogDriver.Name)
assert.Equal(t, "10m", cloned.LogDriver.Options["max-size"])
assert.Equal(t, true, cloned.Privileged)
assert.Equal(t, PullPolicyAlways, cloned.PullPolicy)
assert.Equal(t, int64(1234), cloned.Resources.CPU)
assert.Equal(t, int64(2345), cloned.Resources.Memory)
assert.Equal(t, int64(3456), cloned.Resources.MemoryReservation)
assert.Equal(t, "/dev/sda", cloned.Resources.Devices[0].HostPath)
assert.Equal(t, 1, cloned.Resources.DeviceReservations[0].Count)
assert.Equal(t, "nvidia", cloned.Resources.DeviceReservations[0].Driver)
assert.Equal(t, "1000:1000", cloned.User)
assert.Equal(t, "/data", cloned.Volumes[0])
assert.Equal(t, "/data", cloned.VolumeMounts[0].ContainerPath)
assert.Equal(t, "/etc/config", cloned.ConfigMounts[0].ContainerPath)
assert.NotNil(t, cloned.ConfigMounts[0].Mode)
assert.Equal(t, os.FileMode(0o644), *cloned.ConfigMounts[0].Mode, "Mode should be deep copied")
assert.Equal(t, "1", cloned.Sysctls["net.ipv4.ip_forward"])
}
+3
View File
@@ -83,6 +83,9 @@ func (v *VolumeSpec) SetDefaults() VolumeSpec {
if spec.VolumeOptions.Name == "" {
spec.VolumeOptions.Name = spec.Name
}
if spec.VolumeOptions.Driver != nil && spec.VolumeOptions.Driver.Name == "" {
spec.VolumeOptions.Driver.Name = VolumeDriverLocal
}
}
// TODO: set explicit default values for Propagation and Recursive for bind mounts?
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"testing"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/volume"
"github.com/stretchr/testify/assert"
)
func TestVolumeSpec_MatchesDockerVolume(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec VolumeSpec
vol volume.Volume
expected bool
}{
{
name: "match with explicit local driver",
spec: VolumeSpec{
Type: VolumeTypeVolume,
VolumeOptions: &VolumeOptions{
Driver: &mount.Driver{
Name: "local",
Options: map[string]string{
"foo": "bar",
},
},
},
},
vol: volume.Volume{
Driver: "local",
Options: map[string]string{
"foo": "bar",
},
},
expected: true,
},
{
name: "match with empty driver name in spec (implicit local)",
spec: VolumeSpec{
Type: VolumeTypeVolume,
VolumeOptions: &VolumeOptions{
Driver: &mount.Driver{
Name: "", // Implicitly local
Options: map[string]string{
"foo": "bar",
},
},
},
},
vol: volume.Volume{
Driver: "local",
Options: map[string]string{
"foo": "bar",
},
},
expected: true,
},
{
name: "non-match with different driver options",
spec: VolumeSpec{
Type: VolumeTypeVolume,
VolumeOptions: &VolumeOptions{
Driver: &mount.Driver{
Name: "local",
Options: map[string]string{
"foo": "baz",
},
},
},
},
vol: volume.Volume{
Driver: "local",
Options: map[string]string{
"foo": "bar",
},
},
expected: false,
},
{
name: "non-match with different driver name",
spec: VolumeSpec{
Type: VolumeTypeVolume,
VolumeOptions: &VolumeOptions{
Driver: &mount.Driver{
Name: "custom",
Options: map[string]string{
"foo": "bar",
},
},
},
},
vol: volume.Volume{
Driver: "local",
Options: map[string]string{
"foo": "bar",
},
},
expected: false,
},
{
name: "match external volume without driver by name only",
spec: VolumeSpec{
Name: "external",
Type: VolumeTypeVolume,
},
vol: volume.Volume{
Name: "external",
Driver: "local",
Options: map[string]string{
"foo": "bar",
},
},
expected: true,
},
{
name: "non-match external volume by name",
spec: VolumeSpec{
Name: "unknown",
Type: VolumeTypeVolume,
},
vol: volume.Volume{
Name: "external",
Driver: "local",
Options: map[string]string{
"foo": "bar",
},
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches := tt.spec.MatchesDockerVolume(tt.vol)
assert.Equal(t, tt.expected, matches)
})
}
}
+7
View File
@@ -71,6 +71,13 @@ func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placem
Protocol: api.ProtocolTCP,
Mode: api.PortModeHost,
},
// Needed for HTTP/3 (QUIC)
{
PublishedPort: 443,
ContainerPort: 443,
Protocol: api.ProtocolUDP,
Mode: api.PortModeHost,
},
},
Volumes: []api.VolumeSpec{
{
+39
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"strings"
"github.com/docker/cli/cli/streams"
"github.com/psviderski/uncloud/internal/machine/api/pb"
@@ -76,3 +77,41 @@ func proxyToMachine(ctx context.Context, machine *pb.MachineInfo) context.Contex
md := metadata.Pairs("machines", machineIP.String())
return metadata.NewOutgoingContext(ctx, md)
}
// ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
// If namesOrIDs is nil, all machines are included.
func (cli *Client) ProxyMachinesContext(
ctx context.Context, namesOrIDs []string,
) (context.Context, api.MachineMembersList, error) {
// TODO: move the machine IP resolution to the proxy router to allow setting machine names and IDs in the metadata.
machines, err := cli.ListMachines(ctx, nil)
if err != nil {
return nil, nil, fmt.Errorf("list machines: %w", err)
}
var proxiedMachines api.MachineMembersList
var notFound []string
for _, nameOrID := range namesOrIDs {
if m := machines.FindByNameOrID(nameOrID); m != nil {
proxiedMachines = append(proxiedMachines, m)
} else {
notFound = append(notFound, nameOrID)
}
}
if len(notFound) > 0 {
return nil, nil, fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
}
if len(namesOrIDs) == 0 {
proxiedMachines = machines
}
md := metadata.New(nil)
for _, m := range proxiedMachines {
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
md.Append("machines", machineIP.String())
}
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
}
+12 -1
View File
@@ -1,8 +1,11 @@
package compose
import (
"context"
"os"
"testing"
composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -178,7 +181,15 @@ services:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := loadProjectFromContent(t, tt.composeYAML)
// Get current working directory for relative path resolution to testdata directory.
wd, err := os.Getwd()
require.NoError(t, err)
project, err := LoadProjectFromContent(
context.Background(),
tt.composeYAML,
composecli.WithWorkingDirectory(wd),
)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
+23 -10
View File
@@ -13,18 +13,27 @@ import (
func configSpecsFromCompose(
configs types.Configs, serviceConfigs []types.ServiceConfigObjConfig, workingDir string,
) ([]api.ConfigSpec, []api.ConfigMount, error) {
var configSpecs []api.ConfigSpec
var configMounts []api.ConfigMount
// Temporary map to hold config specs
configSpecsMap := make(map[string]api.ConfigSpec)
// We iterate over all service config objects (config mounts)
for _, serviceConfig := range serviceConfigs {
var spec api.ConfigSpec
if projectConfig, exists := configs[serviceConfig.Source]; exists {
if projectConfig.External {
return nil, nil, fmt.Errorf("external configs are not supported: %s",
serviceConfig.Source)
}
projectConfig, exists := configs[serviceConfig.Source]
if !exists {
return nil, nil, fmt.Errorf("config '%s' not found in project configs", serviceConfig.Source)
}
if projectConfig.External {
return nil, nil, fmt.Errorf("external configs are not supported: %s",
serviceConfig.Source)
}
spec, exists = configSpecsMap[serviceConfig.Source]
if !exists {
spec = api.ConfigSpec{
Name: serviceConfig.Source,
Content: []byte(projectConfig.Content),
@@ -44,11 +53,9 @@ func configSpecsFromCompose(
}
spec.Content = fileContent
}
} else {
return nil, nil, fmt.Errorf("config '%s' not found in project configs", serviceConfig.Source)
}
configSpecs = append(configSpecs, spec)
configSpecsMap[serviceConfig.Source] = spec
}
// Create config mount
target := serviceConfig.Target
@@ -71,5 +78,11 @@ func configSpecsFromCompose(
configMounts = append(configMounts, mount)
}
var configSpecs []api.ConfigSpec
// Convert config spec map to slice
for _, spec := range configSpecsMap {
configSpecs = append(configSpecs, spec)
}
return configSpecs, configMounts, nil
}
+141
View File
@@ -77,6 +77,147 @@ func TestConfigSpecsFromCompose(t *testing.T) {
},
},
},
{
name: "same source mounted to different targets",
configs: types.Configs{
"shared-config": types.ConfigObjConfig{
File: "testdata/config1.txt",
},
},
serviceConfigs: []types.ServiceConfigObjConfig{
{
Source: "shared-config",
Target: "/app/config.json",
UID: "1000",
GID: "1000",
},
{
Source: "shared-config",
Target: "/backup/config.json",
UID: "1001",
GID: "1001",
},
},
expectedSpecs: []api.ConfigSpec{
{
Name: "shared-config",
Content: []byte("test config content\n"),
},
},
expectedMounts: []api.ConfigMount{
{
ConfigName: "shared-config",
ContainerPath: "/app/config.json",
Uid: "1000",
Gid: "1000",
},
{
ConfigName: "shared-config",
ContainerPath: "/backup/config.json",
Uid: "1001",
Gid: "1001",
},
},
},
{
name: "config with default target path",
configs: types.Configs{
"default-config": types.ConfigObjConfig{
Content: "inline config content",
},
},
serviceConfigs: []types.ServiceConfigObjConfig{
{
Source: "default-config",
// No Target specified - should use default
},
},
expectedSpecs: []api.ConfigSpec{
{
Name: "default-config",
Content: []byte("inline config content"),
},
},
expectedMounts: []api.ConfigMount{
{
ConfigName: "default-config",
ContainerPath: "/default-config",
},
},
},
{
name: "config with inline content",
configs: types.Configs{
"inline-config": types.ConfigObjConfig{
Content: "server {\n listen 80;\n}",
},
},
serviceConfigs: []types.ServiceConfigObjConfig{
{
Source: "inline-config",
Target: "/etc/nginx/sites-available/default",
Mode: func() *types.FileMode { m := types.FileMode(0o755); return &m }(),
},
},
expectedSpecs: []api.ConfigSpec{
{
Name: "inline-config",
Content: []byte("server {\n listen 80;\n}"),
},
},
expectedMounts: []api.ConfigMount{
{
ConfigName: "inline-config",
ContainerPath: "/etc/nginx/sites-available/default",
Mode: func() *os.FileMode { m := os.FileMode(0o755); return &m }(),
},
},
},
{
name: "config not found error",
configs: types.Configs{
"existing-config": types.ConfigObjConfig{
Content: "some content",
},
},
serviceConfigs: []types.ServiceConfigObjConfig{
{
Source: "missing-config",
Target: "/app/config.json",
},
},
expectError: true,
},
{
name: "external config error",
configs: types.Configs{
"external-config": types.ConfigObjConfig{
External: true,
},
},
serviceConfigs: []types.ServiceConfigObjConfig{
{
Source: "external-config",
Target: "/app/config.json",
},
},
expectError: true,
},
{
name: "file not found error",
configs: types.Configs{
"missing-file-config": types.ConfigObjConfig{
File: "testdata/nonexistent.txt",
},
},
serviceConfigs: []types.ServiceConfigObjConfig{
{
Source: "missing-file-config",
Target: "/app/config.json",
},
},
expectError: true,
},
}
for _, tt := range tests {
+10 -12
View File
@@ -13,6 +13,7 @@ import (
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
)
@@ -27,7 +28,7 @@ type Deployment struct {
SpecResolver *deploy.ServiceSpecResolver
Strategy deploy.Strategy
state *scheduler.ClusterState
plan *deploy.SequenceOperation
plan *operation.SequenceOperation
}
func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) {
@@ -49,10 +50,6 @@ func NewDeploymentWithStrategy(ctx context.Context, cli Client, project *types.P
ClusterDomain: domain,
}
if strategy == nil {
strategy = &deploy.RollingStrategy{State: state}
}
return &Deployment{
Client: cli,
Project: project,
@@ -62,11 +59,11 @@ func NewDeploymentWithStrategy(ctx context.Context, cli Client, project *types.P
}, nil
}
func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error) {
func (d *Deployment) Plan(ctx context.Context) (operation.SequenceOperation, error) {
if d.plan != nil {
return *d.plan, nil
}
plan := deploy.SequenceOperation{}
plan := operation.SequenceOperation{}
// Generate service specs for all services in the project.
var serviceSpecs []api.ServiceSpec
@@ -88,6 +85,7 @@ func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error)
}
// Check external volumes and plan the creation of missing volumes before deploying services.
// Updates the cluster state (d.state) with the scheduled volumes.
volumeOps, err := d.planVolumes(serviceSpecs)
if err != nil {
return plan, err
@@ -98,8 +96,8 @@ func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error)
for _, spec := range serviceSpecs {
// TODO: properly handle depends_on conditions in the service deployment plan as the first operation.
// Pass the update cluster state with scheduled volumes to the deployment.
deployment := deploy.NewDeployment(d.Client, spec, d.Strategy)
// Pass the updated cluster state with the scheduled volumes to the deployment.
deployment := deploy.NewDeploymentWithClusterState(d.Client, spec, d.Strategy, d.state)
servicePlan, err := deployment.Plan(ctx)
if err != nil {
return plan, fmt.Errorf("create deployment plan for service '%s': %w", spec.Name, err)
@@ -126,7 +124,7 @@ func (d *Deployment) ServiceSpec(name string) (api.ServiceSpec, error) {
}
// PlanVolumes checks if the external volumes exist and plans the creation of missing volumes.
func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.CreateVolumeOperation, error) {
func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*operation.CreateVolumeOperation, error) {
if len(d.Project.Volumes) == 0 {
// No volumes to check or create.
return nil, nil
@@ -148,7 +146,7 @@ func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.Crea
}
// Generate operations to create scheduled missing volumes.
var ops []*deploy.CreateVolumeOperation
var ops []*operation.CreateVolumeOperation
for machineID, volumes := range scheduledVolumes {
for _, v := range volumes {
machineName := machineID
@@ -156,7 +154,7 @@ func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.Crea
machineName = m.Info.Name
}
ops = append(ops, &deploy.CreateVolumeOperation{
ops = append(ops, &operation.CreateVolumeOperation{
MachineID: machineID,
MachineName: machineName,
VolumeSpec: v,
+4 -3
View File
@@ -1,6 +1,7 @@
package compose
import (
"context"
"net/netip"
"testing"
@@ -301,7 +302,7 @@ services:
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
project, err := loadProjectFromContent(t, tt.content)
project, err := LoadProjectFromContent(context.Background(), tt.content)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
@@ -375,7 +376,7 @@ services:
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
project, err := loadProjectFromContent(t, tt.content)
project, err := LoadProjectFromContent(context.Background(), tt.content)
require.NoError(t, err)
service, err := project.GetService("web")
@@ -402,7 +403,7 @@ services:
- "9090:9090/tcp@host"
`
project, err := loadProjectFromContent(t, content)
project, err := LoadProjectFromContent(context.Background(), content)
require.NoError(t, err)
service, err := project.GetService("web")
+44
View File
@@ -3,13 +3,26 @@ package compose
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/transform"
"github.com/compose-spec/compose-go/v2/tree"
"github.com/compose-spec/compose-go/v2/types"
"github.com/psviderski/uncloud/pkg/api"
)
var registerComposeOverrides sync.Once
// LoadProject loads a Compose project from the default locations or the given paths.
func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) {
registerComposeOverrides.Do(func() {
transform.RegisterDefaultValue("services.*.deploy.update_config", setUpdateConfigDefaults)
})
defaultOpts := []composecli.ProjectOptionsFn{
// First apply os.Environment, always wins.
composecli.WithOsEnv,
@@ -61,6 +74,26 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
return project, nil
}
// LoadProjectFromContent loads a Compose project from the given YAML content.
func LoadProjectFromContent(
ctx context.Context, content string, opts ...composecli.ProjectOptionsFn,
) (*types.Project, error) {
// Create a temporary directory for the compose file.
tmpDir, err := os.MkdirTemp("", "uncloud-compose-*")
if err != nil {
return nil, fmt.Errorf("create temporary directory: %w", err)
}
defer os.RemoveAll(tmpDir)
// Write the YAML content to compose.yaml in the temporary directory.
composePath := filepath.Join(tmpDir, "compose.yaml")
if err := os.WriteFile(composePath, []byte(content), 0o644); err != nil {
return nil, fmt.Errorf("write compose file: %w", err)
}
return LoadProject(ctx, []string{composePath}, opts...)
}
// removeProjectPrefixFromNames removes the project name prefix from volume names.
func removeProjectPrefixFromNames(project *types.Project) {
prefix := project.Name + "_"
@@ -69,3 +102,14 @@ func removeProjectPrefixFromNames(project *types.Project) {
project.Volumes[name] = vol
}
}
// setUpdateConfigDefaults sets default values for deploy.update_config attributes when not specified in the compose file.
func setUpdateConfigDefaults(data any, _ tree.Path, _ bool) (any, error) {
switch v := data.(type) {
case map[string]any:
if _, ok := v["monitor"]; !ok {
v["monitor"] = api.DefaultHealthMonitorPeriod.String()
}
}
return data, nil
}
+109 -10
View File
@@ -5,12 +5,14 @@ import (
"maps"
"os"
"slices"
"time"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/opencontainers/go-digest"
"github.com/psviderski/uncloud/pkg/api"
cdi "tags.cncf.io/container-device-interface/pkg/parser"
)
func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.ServiceSpec, error) {
@@ -43,15 +45,19 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
spec := api.ServiceSpec{
Container: api.ContainerSpec{
Command: service.Command,
Entrypoint: service.Entrypoint,
Env: env,
Image: service.Image,
Init: service.Init,
Privileged: service.Privileged,
PullPolicy: pullPolicy,
Resources: resourcesFromCompose(service),
User: service.User,
CapAdd: service.CapAdd,
CapDrop: service.CapDrop,
Command: service.Command,
Entrypoint: service.Entrypoint,
Env: env,
Healthcheck: healthcheckFromCompose(service.HealthCheck),
Image: service.Image,
Init: service.Init,
Privileged: service.Privileged,
PullPolicy: pullPolicy,
Resources: resourcesFromCompose(service),
Sysctls: service.Sysctls,
User: service.User,
},
Name: serviceName,
Mode: api.ServiceModeReplicated,
@@ -94,6 +100,22 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
default:
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
}
if cfg := service.Deploy.UpdateConfig; cfg != nil {
switch cfg.Order {
case "":
// No order specified, use default behavior.
case "start-first":
spec.UpdateConfig.Order = api.UpdateOrderStartFirst
case "stop-first":
spec.UpdateConfig.Order = api.UpdateOrderStopFirst
default:
return spec, fmt.Errorf("unsupported deploy.update_config.order: '%s'", cfg.Order)
}
d := time.Duration(cfg.Monitor)
spec.UpdateConfig.MonitorPeriod = &d
}
}
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
@@ -117,11 +139,61 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
return spec, nil
}
func healthcheckFromCompose(hc *types.HealthCheckConfig) *api.HealthcheckSpec {
if hc == nil {
return nil
}
if hc.Disable {
return &api.HealthcheckSpec{Disable: true}
}
spec := &api.HealthcheckSpec{Test: hc.Test}
if hc.Interval != nil {
spec.Interval = time.Duration(*hc.Interval)
}
if hc.Timeout != nil {
spec.Timeout = time.Duration(*hc.Timeout)
}
if hc.StartPeriod != nil {
spec.StartPeriod = time.Duration(*hc.StartPeriod)
}
if hc.StartInterval != nil {
spec.StartInterval = time.Duration(*hc.StartInterval)
}
if hc.Retries != nil {
spec.Retries = uint(*hc.Retries)
}
return spec
}
func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
resources := api.ContainerResources{
CPU: int64(service.CPUS * 1e9),
Memory: int64(service.MemLimit),
MemoryReservation: int64(service.MemReservation),
Ulimits: ulimitsFromCompose(service.Ulimits),
}
// Convert device mappings, separating CDI devices from regular device mappings.
// CDI devices are identified when Source == Target and the source is a qualified CDI name.
var cdiDeviceNames []string
for _, dev := range service.Devices {
if dev.Source == dev.Target && cdi.IsQualifiedName(dev.Source) {
cdiDeviceNames = append(cdiDeviceNames, dev.Source)
continue
}
resources.Devices = append(resources.Devices, api.DeviceMapping{
HostPath: dev.Source,
ContainerPath: dev.Target,
CgroupPermissions: dev.Permissions,
})
}
if len(cdiDeviceNames) > 0 {
resources.DeviceReservations = append(resources.DeviceReservations, container.DeviceRequest{
Driver: "cdi",
DeviceIDs: cdiDeviceNames,
})
}
// Convert GPU device requests from compose format, appending "gpu" capability.
@@ -249,7 +321,7 @@ func dockerVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig, volume
}
if !volume.External {
if volume.Driver != "" {
if volume.Driver != "" || len(volume.DriverOpts) > 0 {
spec.VolumeOptions.Driver = &mount.Driver{
Name: volume.Driver,
Options: volume.DriverOpts,
@@ -290,6 +362,33 @@ func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.Vol
return spec
}
func ulimitsFromCompose(ulimits map[string]*types.UlimitsConfig) map[string]api.Ulimit {
if len(ulimits) == 0 {
return nil
}
res := make(map[string]api.Ulimit, len(ulimits))
for name, u := range ulimits {
soft := u.Soft
hard := u.Hard
if u.Single != 0 {
if soft == 0 {
soft = u.Single
}
if hard == 0 {
hard = u.Single
}
}
res[name] = api.Ulimit{
Soft: int64(soft),
Hard: int64(hard),
}
}
return res
}
// validateServicesExtensions validates extension combinations across all services in the project.
func validateServicesExtensions(project *types.Project) error {
for _, service := range project.Services {
+552 -57
View File
@@ -3,13 +3,14 @@ package compose
import (
"context"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/compose-spec/compose-go/v2/loader"
"github.com/compose-spec/compose-go/v2/types"
composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/go-units"
@@ -20,57 +21,6 @@ import (
"github.com/stretchr/testify/require"
)
// loadProjectFromContent loads a compose project from YAML content.
// Keep the implementation in sync with LoadProject.
func loadProjectFromContent(t *testing.T, content string) (*types.Project, error) {
t.Helper()
ctx := context.Background()
configDetails := types.ConfigDetails{
ConfigFiles: []types.ConfigFile{
{
Filename: "docker-compose.yml",
Content: []byte(content),
},
},
}
project, err := loader.LoadWithContext(ctx, configDetails, func(o *loader.Options) {
o.SetProjectName("test", true)
// Register our custom extensions
if o.KnownExtensions == nil {
o.KnownExtensions = map[string]any{}
}
o.KnownExtensions[CaddyExtensionKey] = Caddy{}
o.KnownExtensions[PortsExtensionKey] = PortsSource{}
o.KnownExtensions[MachinesExtensionKey] = MachinesSource{}
})
if err != nil {
return nil, err
}
removeProjectPrefixFromNames(project)
// Apply extension transformations since we're not using LoadProject.
if project, err = transformServicesCaddyExtension(project); err != nil {
return nil, err
}
if project, err = transformServicesPortsExtension(project); err != nil {
return nil, err
}
// Validate extension combinations after all transformations.
if err = validateServicesExtensions(project); err != nil {
return nil, err
}
// Process image templates in services to expand Go template expressions using git repo state.
if project, err = ProcessImageTemplates(project); err != nil {
return nil, err
}
return project, nil
}
func TestServiceSpecFromCompose(t *testing.T) {
t.Parallel()
@@ -144,13 +94,15 @@ func TestServiceSpecFromCompose(t *testing.T) {
},
},
{
name: "full-spec",
name: "full spec",
filename: "compose-full-spec.yaml",
want: map[string]api.ServiceSpec{
"test": {
Name: "test",
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
CapAdd: []string{"NET_ADMIN"},
CapDrop: []string{"ALL"},
Command: []string{"nginx", "updated", "command"},
Entrypoint: []string{"/updated-docker-entrypoint.sh"},
Env: map[string]string{
@@ -158,6 +110,14 @@ func TestServiceSpecFromCompose(t *testing.T) {
"EMPTY": "",
"VAR": "value",
},
Healthcheck: &api.HealthcheckSpec{
Test: []string{"CMD", "curl", "-f", "http://localhost"},
Interval: 1*time.Minute + 30*time.Second,
Timeout: 10 * time.Second,
Retries: 5,
StartPeriod: 15 * time.Second,
StartInterval: 2 * time.Second,
},
Image: "nginx:latest",
Init: &initTrue,
LogDriver: &api.LogDriver{
@@ -173,6 +133,20 @@ func TestServiceSpecFromCompose(t *testing.T) {
CPU: 0.5 * api.Core,
Memory: 100 * units.MiB,
MemoryReservation: 50 * units.MiB,
Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 20000, Hard: 40000},
"nproc": {Soft: 65535, Hard: 65535},
},
Devices: []api.DeviceMapping{
{HostPath: "/dev/ttyUSB0", ContainerPath: "/dev/ttyUSB0", CgroupPermissions: "rw"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
},
DeviceReservations: []container.DeviceRequest{
{Count: -1, Capabilities: [][]string{{"gpu"}}},
},
},
Sysctls: map[string]string{
"net.ipv4.ip_forward": "1",
},
User: "nginx:nginx",
VolumeMounts: []api.VolumeMount{
@@ -185,6 +159,15 @@ func TestServiceSpecFromCompose(t *testing.T) {
VolumeName: "data1",
ContainerPath: "/data1",
},
{
VolumeName: "bind-53f1acbf1de61e9e608c93effca23791674e463d02bb7aaca7c625804aef1926",
ContainerPath: "/path/in/container",
ReadOnly: true,
},
{
VolumeName: "data3-labeled",
ContainerPath: "/data3",
},
{
VolumeName: "data2-alias",
ContainerPath: "/data2/long/syntax",
@@ -219,8 +202,24 @@ func TestServiceSpecFromCompose(t *testing.T) {
Mode: api.PortModeHost,
},
},
Placement: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
Replicas: 3,
UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
Volumes: []api.VolumeSpec{
{
Name: "bind-53f1acbf1de61e9e608c93effca23791674e463d02bb7aaca7c625804aef1926",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/path/on/host",
CreateHostPath: true,
Propagation: mount.Propagation("rprivate"),
},
},
{
Name: "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90",
Type: api.VolumeTypeBind,
@@ -253,11 +252,22 @@ func TestServiceSpecFromCompose(t *testing.T) {
},
},
},
{
Name: "data3-labeled",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "data3-labeled",
NoCopy: true,
SubPath: "app/data",
Labels: map[string]string{"env": "test"},
},
},
{
Name: "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542",
Type: api.VolumeTypeTmpfs,
TmpfsOptions: &mount.TmpfsOptions{
SizeBytes: 10 * units.MiB,
Mode: os.FileMode(1770),
},
},
},
@@ -303,6 +313,8 @@ func TestServiceSpecFromCompose(t *testing.T) {
}
func TestServiceSpecFromCompose_Caddy(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
@@ -431,7 +443,15 @@ services:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := loadProjectFromContent(t, tt.composeYAML)
// Get current working directory for relative path resolution to testdata directory.
wd, err := os.Getwd()
require.NoError(t, err)
project, err := LoadProjectFromContent(
context.Background(),
tt.composeYAML,
composecli.WithWorkingDirectory(wd),
)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "web")
@@ -443,6 +463,8 @@ services:
}
func TestServiceSpecFromCompose_GPUs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
@@ -617,7 +639,7 @@ services:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := loadProjectFromContent(t, tt.composeYAML)
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "ai")
@@ -629,7 +651,379 @@ services:
}
}
func TestServiceSpecFromCompose_VolumeDriverOpts(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expectedVolume api.VolumeSpec
}{
{
name: "volume with driver_opts only (no driver specified)",
composeYAML: `
services:
test:
image: nginx
volumes:
- nfsmount:/data/nfs
volumes:
nfsmount:
driver_opts:
type: "nfs"
o: "addr=192.168.1.100,nolock,soft"
device: ":/mnt/share"
`,
expectedVolume: api.VolumeSpec{
Name: "nfsmount",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "nfsmount",
Driver: &mount.Driver{
Options: map[string]string{
"type": "nfs",
"o": "addr=192.168.1.100,nolock,soft",
"device": ":/mnt/share",
},
},
},
},
},
{
name: "volume with driver and driver_opts",
composeYAML: `
services:
test:
image: nginx
volumes:
- nfsmount:/data/nfs
volumes:
nfsmount:
driver: local
driver_opts:
type: "nfs"
o: "addr=192.168.1.100,nolock,soft"
device: ":/mnt/share"
`,
expectedVolume: api.VolumeSpec{
Name: "nfsmount",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "nfsmount",
Driver: &mount.Driver{
Name: "local",
Options: map[string]string{
"type": "nfs",
"o": "addr=192.168.1.100,nolock,soft",
"device": ":/mnt/share",
},
},
},
},
},
{
name: "external volume ignores driver_opts",
composeYAML: `
services:
test:
image: nginx
volumes:
- extvolume:/data
volumes:
extvolume:
external: true
`,
expectedVolume: api.VolumeSpec{
Name: "extvolume",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "extvolume",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "test")
require.NoError(t, err)
require.Len(t, spec.Volumes, 1, "expected exactly one volume")
actualVolume := spec.Volumes[0]
cmpOpts := cmp.Options{cmpopts.EquateEmpty()}
assert.True(t, cmp.Equal(actualVolume, tt.expectedVolume, cmpOpts...),
"Volume mismatch:\n%s", cmp.Diff(actualVolume, tt.expectedVolume, cmpOpts...))
})
}
}
func TestServiceSpecFromCompose_Ulimits(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expected map[string]api.Ulimit
}{
{
name: "single ulimit with soft and hard limits",
composeYAML: `
services:
db:
image: postgres
ulimits:
nofile:
soft: 20000
hard: 40000
`,
expected: map[string]api.Ulimit{
"nofile": {
Soft: 20000,
Hard: 40000,
},
},
},
{
name: "single ulimit with single value (soft=hard)",
composeYAML: `
services:
db:
image: postgres
ulimits:
nproc: 65535
`,
expected: map[string]api.Ulimit{
"nproc": {
Soft: 65535,
Hard: 65535,
},
},
},
{
name: "multiple ulimits",
composeYAML: `
services:
db:
image: postgres
ulimits:
nofile:
soft: 20000
hard: 40000
nproc: 65535
`,
expected: map[string]api.Ulimit{
"nofile": {
Soft: 20000,
Hard: 40000,
},
"nproc": {
Soft: 65535,
Hard: 65535,
},
},
},
{
name: "empty ulimits",
composeYAML: `
services:
db:
image: postgres
ulimits: {}
`,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "db")
require.NoError(t, err)
assert.Equal(t, tt.expected, spec.Container.Resources.Ulimits)
})
}
}
func TestServiceSpecFromCompose_UpdateConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expected api.UpdateConfig
expectError bool
}{
{
name: "no update_config",
composeYAML: `
services:
test:
image: nginx
`,
expected: api.UpdateConfig{},
},
{
name: "empty update_config",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config: {}
`,
expected: api.UpdateConfig{
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with unsupported attributes ignored",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
parallelism: 1
`,
expected: api.UpdateConfig{
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with stop-first order",
composeYAML: `
services:
test:
image: postgres
deploy:
update_config:
order: stop-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with start-first order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: start-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStartFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with invalid order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: invalid-order
`,
expectError: true,
},
{
name: "update_config with replicas and order",
composeYAML: `
services:
test:
image: nginx
deploy:
replicas: 3
update_config:
order: stop-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with custom monitor",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
monitor: 10s
`,
expected: api.UpdateConfig{
MonitorPeriod: api.AsPtr(10 * time.Second),
},
},
{
name: "update_config with monitor and order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: start-first
monitor: 30s
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStartFirst,
MonitorPeriod: api.AsPtr(30 * time.Second),
},
},
{
name: "update_config with zero monitor skips monitoring",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
monitor: 0s
`,
expected: api.UpdateConfig{
MonitorPeriod: api.AsPtr(time.Duration(0)),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "test")
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, spec.UpdateConfig)
})
}
}
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
@@ -765,7 +1159,7 @@ services:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := loadProjectFromContent(t, tt.composeYAML)
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
if tt.expectError {
assert.Error(t, err)
@@ -786,3 +1180,104 @@ services:
})
}
}
func TestServiceSpecFromCompose_Devices(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expectedDevices []api.DeviceMapping
expectedReservations []container.DeviceRequest
}{
{
name: "simple device",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/dri
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
},
},
{
name: "device with target and permissions",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/sda:/dev/xvda:r
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "r"},
},
},
{
name: "multiple devices",
composeYAML: `
services:
test:
image: nginx
devices:
- "/dev/ttyUSB0:/dev/ttyUSB0:rw"
- /dev/sda:/dev/xvda
- "/dev/dri"
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/ttyUSB0", ContainerPath: "/dev/ttyUSB0", CgroupPermissions: "rw"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
},
},
{
name: "CDI device",
composeYAML: `
services:
test:
image: nginx
devices:
- vendor.com/class=device1
`,
expectedReservations: []container.DeviceRequest{
{Driver: "cdi", DeviceIDs: []string{"vendor.com/class=device1"}},
},
},
{
name: "mixed CDI and regular devices",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/dri
- vendor.com/class=device1
- nvidia.com/gpu=0
- /dev/sda:/dev/xvda:r
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "r"},
},
expectedReservations: []container.DeviceRequest{
{Driver: "cdi", DeviceIDs: []string{"vendor.com/class=device1", "nvidia.com/gpu=0"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "test")
require.NoError(t, err)
assert.Equal(t, tt.expectedDevices, spec.Container.Resources.Devices)
assert.Equal(t, tt.expectedReservations, spec.Container.Resources.DeviceReservations)
})
}
}
+43
View File
@@ -1,12 +1,30 @@
services:
test:
cap_add:
- NET_ADMIN
cap_drop:
- ALL
command: ["nginx", "updated", "command"]
cpus: 0.5
deploy:
update_config:
order: stop-first
devices:
- /dev/ttyUSB0:/dev/ttyUSB0:rw
- /dev/sda:/dev/xvda
entrypoint: ["/updated-docker-entrypoint.sh"]
environment:
BOOL: "true"
EMPTY: ""
VAR: value
gpus: all
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 1m30s
timeout: 10s
retries: 5
start_period: 15s
start_interval: 2s
image: nginx:latest
init: true
logging:
@@ -19,10 +37,30 @@ services:
privileged: true
pull_policy: always
scale: 3
sysctls:
- net.ipv4.ip_forward=1
ulimits:
nofile:
soft: 20000
hard: 40000
nproc: 65535
user: nginx:nginx
volumes:
- /etc/passwd:/host/etc/passwd:ro
- data1:/data1
- type: bind
source: /path/on/host
target: /path/in/container
read_only: true
bind:
create_host_path: true
propagation: rprivate
- type: volume
source: data3-labeled
target: /data3
volume:
nocopy: true
subpath: app/data
- type: volume
source: data2-alias
target: /data2/long/syntax
@@ -31,6 +69,8 @@ services:
target: /tmpfs
tmpfs:
size: 10485760
mode: 1770
x-machines: ["machine-1", "machine-2"]
x-ports:
- test.example.com:80/https
- 8000/http
@@ -49,5 +89,8 @@ volumes:
data2-alias:
name: data2
driver: local
data3-labeled:
labels:
env: test
data-external:
external: true
+30
View File
@@ -0,0 +1,30 @@
package connector
import (
"encoding/json"
"fmt"
)
// defaultServiceConfig defines the default gRPC service configuration including retry policy for transient failures.
var defaultServiceConfig = mustMarshalJSON(map[string]any{
"methodConfig": []map[string]any{
{
"name": []map[string]string{{"service": ""}},
"retryPolicy": map[string]any{
"maxAttempts": 5, // 5 is the maximum allowed by gRPC
"initialBackoff": "0.5s",
"maxBackoff": "5s",
"backoffMultiplier": 2,
"retryableStatusCodes": []string{"UNAVAILABLE"},
},
},
},
})
func mustMarshalJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
panic(fmt.Sprintf("failed to marshal service config: %v", err))
}
return string(b)
}

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