Compare commits

...
175 Commits
Author SHA1 Message Date
Pasha Sviderski 0b32cd2095 chore: upgrade unregistry 0.3.1 -> 0.4.1 with a fix for pushing big images 2025-12-16 20:08:40 +10:00
Pasha Sviderski 425b974331 fix: panic when initialising new cluster with --connect 2025-12-16 11:28:11 +10:00
Pasha Sviderski db09dec855 chore: add more tests for MatchesDockerVolume and update error message when volume spec doesn't match 2025-12-15 18:12:12 +10:00
Justin BradfordandGitHub 20cdcdf917 fix: Set volume driver name to "local" if not specified (#219) 2025-12-15 17:50:36 +10:00
Pasha Sviderski 796363f23c fix: TestCollectContainers unit tests 2025-12-12 21:02:22 +10:00
Pasha Sviderski cb0df2169f feat: add IP ADDRESS column to 'uc ps' and 'uc inspect' 2025-12-12 20:23:43 +10:00
Pasha Sviderski 95afc7f289 chore: sort 'uc inspect' containers by CREATED 2025-12-12 20:13:02 +10:00
Pasha Sviderski bc65c73c51 chore: add CREATED column to 'uc ps', fallback sorting by CREATED 2025-12-12 20:01:49 +10:00
Pasha Sviderski 596bc561b8 chore: 'uc stop' - make --timeout default explicit, update flag and long descriptions 2025-12-12 19:16:17 +10:00
Pasha Sviderski f215fae6c7 chore: group deploy and service management commands in uc --help 2025-12-12 18:52:08 +10:00
Pasha Sviderski 5707c37b63 docs: replace managed DNS domain: cluster.uncloud.run -> uncld.dev 2025-12-12 18:21:11 +10:00
Pasha Sviderski 5780d3a8a4 docs: regenereate CLI reference 2025-12-10 20:48:28 +10:00
Pasha Sviderski ac18ff3e8b docs: add examples section to uc logs command 2025-12-10 20:47:42 +10:00
a9a720f3c3 feat: Add uc service start/stop commands (#195)
* feat: Add `uc service start/stop` commands

* Add nolint:dupl for service start/stop commands

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

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

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

---------

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

* expose udp port 443 for caddy to support new HTTP3

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

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

---------

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

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

* revert Makefile

* fix after rebase

* move logs command under services with root shortcut

* simplify server options for streaming logs and CLI flags

* refactor ContainerLogs grpc server

* update --tail flag

* minor docker.proto

* refactor client ServiceLogs and ContainerLogs

* move ProxyMachinesContext from api to client pkg

* minor refactor proto Stream

* add api/logs

* implement LogMerger

* fix LogMerger to correctly use semaphore

* increate inflish entries to 100 per stream

* send heartbeats

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

* minor logmerege

* remove ContainerName form ServiceLogEntryMetadata

* refactor ContainerLogs into docker.Service

* detect stalled container logs streams

* update logmerger tests

* fix comment in test

* refactor LogMerger with options

* uc logs: format one or multiple services

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

* update uc logs with new LogMerger

* go mod tidy

* fix after merge

---------

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

* Fix indentation

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

* Add group/sort option by "health"

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

* Use huh.spinner with standard style/type

* Remove pointless TrimPrefix on container name

* Move machine lookup out of services loop

* Use more efficient `ListServiceContainers`

* Use consistent header other

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

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

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

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

* Update cmd/uncloud/main.go

---------

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

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

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

* Remove unused dns showOptions struct

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

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

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

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

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

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

* Add UseSSHCLI field to RemoteMachine struct

Allow us to differentiate regular ssh from ssh+cli.

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

* Support SSH CLI when provisioning

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

* Revert removal of `init` examples

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

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

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

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

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

Resolves #131

* Fix sshcli tests missing ConnectionTimeout

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

* Add SSHCLI field and update MachineConnection String() format

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

* Add Validate() method and tests for MachineConnection

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

* Unify SSH connector configs to use SSHConnectorConfig

* Update connectCluster to support both SSH connector types

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

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

SSH CLI connector remains available via ssh_cli config field.

* Add sshCLIDialer with DialContext method

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

* Implement SSHCLIConnector.Dialer() method

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

* Fix half-closing implementation matchin Docker's approach

* Use testify assertions for connection tests

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

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

  $ unset SSH_AUTH_SOCK

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

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

* Validates configuration before connecting to cluster

* Do not tie client constructor with real validation

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

Later on we could validate it in different places.

* Cleanup test and remove AI-slop

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

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

* Fix connection representation tests

Prefix connection with ssh ssh+cli respectively.

* Wait for stdout before returning

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

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

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

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

* Add test on nearest internal DNS lookup

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

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

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

---------

Co-authored-by: Pasha Sviderski <me@psviderski.name>
2025-10-07 14:12:00 +10:00
Pasha Sviderski 784d86d8e8 fix: test build for docker server 2025-10-03 21:10:02 +10:00
Pasha Sviderski 3bac1e61b9 chore: go mod tidy 2025-10-03 20:54:59 +10:00
Pasha Sviderski 6d2e100d63 feat(images): 'uc image ls' and 'uc images' (alias) commands to list images on machines 2025-10-03 20:53:30 +10:00
Pasha Sviderski cce81ecaad chore(images): simplifies ListImages service, server, and client to only list Docker images and not directly access containerd 2025-10-03 18:25:39 +10:00
Pasha Sviderski f8bf6bccd3 test: basic e2e test for ListImages 2025-10-03 16:55:20 +10:00
Pasha Sviderski 82fd9744bf chore: remove unused imports 2025-10-03 15:03:19 +10:00
Pasha Sviderski cdff036935 feat(images): gRPC client and server for ListImages 2025-10-03 14:23:18 +10:00
Pasha Sviderski 458d282357 chore: correct comment and add TODO to move machine resolution to grpc-proxy router 2025-10-02 20:12:45 +10:00
Pasha Sviderski 59074a275f chore(images): initialise Docker service with containerd client 2025-10-02 15:51:54 +10:00
Pasha Sviderski 92975eec51 feat(images): add gRPC proto ListImages 2025-10-02 12:50:53 +10:00
Pasha Sviderski e0d11a8abc chore: configure Docker to use containerd image store by default (closes #129) 2025-10-01 15:00:19 +10:00
Pasha Sviderski fa004d63bb chore: add firewall rule to allow cluster machines to push to other machines unregistry 2025-10-01 12:34:03 +10:00
Pasha Sviderski a217643ac9 chore: shorten socat proxy container progress message 2025-09-30 19:44:54 +10:00
Pasha Sviderski fc3297c7b7 chore: go mod tidy 2025-09-30 19:31:14 +10:00
Pasha Sviderski 684f0d38ee feat(push): '--platform all' to push image to all machines in cluster 2025-09-30 19:23:01 +10:00
Pasha Sviderski 332e7bca90 feat(push): --platform flag to push a specific platform of a multi-platform image 2025-09-30 18:59:03 +10:00
Pasha Sviderski ae458d260f chore: refactor duplication 2025-09-30 15:42:01 +10:00
Pasha Sviderski 6b758c810e docs: image push example 2025-09-30 15:24:40 +10:00
Pasha Sviderski ffed906cef feat(push): 'image push' to single or multiple machines via unregistry proxy 2025-09-30 15:18:26 +10:00
Pasha Sviderski bcc504b8cc chore: internal Docker client PushImage 2025-09-30 13:58:34 +10:00
Anton Ovchinnikov 782f4cb20b chore: Update AI.md with instructions around tests and docs 2025-09-29 00:02:58 +02:00
Pasha Sviderski aebd2ac232 docs: review and update Compose support matrix 2025-09-26 22:42:07 +10:00
Pasha Sviderski 2969b40ad4 chore: return a proxy dialer for SSH connections 2025-09-26 21:24:56 +10:00
Pasha Sviderski e0045c7570 chore: refactor gRPC docker client 2025-09-26 21:24:56 +10:00
Pasha Sviderski bc6f2fd49d chore: fix docker client CreateContainerWithImagePull, add WaitPortPublished 2025-09-26 21:24:56 +10:00
Pasha Sviderski cd25b973ea fix: docker client PullImage 2025-09-26 21:24:56 +10:00
Pasha Sviderski 27bbbe7186 chore: internal docker package with handy PullImage and CreateContainerWithImagePull methods 2025-09-26 21:24:56 +10:00
Pasha Sviderski 20cf98d42e fix: return an error if 'uc rm' tries to remove a service that doesn't exist 2025-09-26 21:24:56 +10:00
Anton OvchinnikovandGitHub 63c4de512e feat: Initial support for Compose configs (#116) 2025-09-26 21:24:10 +10:00
Anton Ovchinnikov 337c15de35 chore: Add node to Mise 2025-09-24 23:16:36 +02:00
Justin BradfordandGitHub 2d361d3ee6 Adding machine.service.internal dns entries (#123) 2025-09-24 20:06:54 +10:00
Pasha Sviderski 977fdf3cc3 chore: add Proxy for proxing local connections to a remote TCP address using the dialer 2025-09-24 19:52:37 +10:00
Pasha Sviderski 9f6880b701 feat(push): stub for 'image push' command and Dialer interface for cluster connectors 2025-09-23 16:54:47 +10:00
Pasha Sviderski 1a1afece7d fix: e2e test rebind the registry port to unoccupied 5001 2025-09-23 15:26:36 +10:00
Pasha Sviderski 08f233c5b1 fix: auto-detect containerd.sock path and conditionally start unregistry 2025-09-23 15:10:34 +10:00
Pasha Sviderski 9438654d99 docs: fix comment in schema.sql 2025-09-23 12:21:45 +10:00
Pasha Sviderski 60926f64cc feat: show IMAGE column when listing services (uc ls) #13 2025-09-22 14:03:45 +10:00
Pasha Sviderski b6b8286118 feat: run embedded unregistry on machine IP in uncloudd daemon 2025-09-19 20:43:20 +10:00
Pasha Sviderski 8f906cfd95 fix: do not auto-confirm deploy plan on CI (no TTY), require explicit --yes or UNCLOUD_AUTO_CONFIRM=true 2025-09-18 12:12:06 +10:00
Pasha Sviderski da03ed8ece docs: generate CLI reference docs 2025-09-17 15:11:36 +10:00
Pasha Sviderski 7239cd3a6b docs: clarify 'machine init' help, --context flag description, add examples 2025-09-17 15:10:59 +10:00
Pasha Sviderski 3db07ca2d4 feat: allow setting config and connection with UNCLOUD_CONFIG and UNCLOUD_CONNECT env vars 2025-09-16 10:17:44 +10:00
Pasha Sviderski f8fe3c7af3 feat: --no-install flag for 'machine init/add' to skip installing Uncloud daemon and dependencies #122 2025-09-15 20:44:35 +10:00
Pasha Sviderski b6e9766f40 lint: gRPC status.Error for static error messages 2025-09-15 17:11:12 +10:00
Pasha Sviderski 1f62e30a08 chore: update mise checksums for linux-x64 2025-09-13 22:02:11 +10:00
Pasha Sviderski 88f05ffd75 chore: update mise checksums for macos-arm64 2025-09-13 21:50:40 +10:00
Pasha Sviderski 375bb385a2 chore: bump golangci-lint to 2.4.0 on CI 2025-09-13 21:44:45 +10:00
Pasha Sviderski 51840e0366 chore: bump golangci-lint to 2.4.0 2025-09-13 21:20:48 +10:00
Pasha Sviderski 831c581e0f feat: try each cluster connection in order until one succeeds, add progress spinner 2025-09-13 21:12:20 +10:00
Pasha Sviderski e1734a1aa6 chore: bump Go to 1.25.1 2025-09-13 21:06:17 +10:00
Pasha Sviderski 84376bb34c chore: mise.lock include checksums for linux-x64 2025-09-12 23:18:45 +10:00
Pasha Sviderski 6d0b1dc1ee chore bump mise version on CI to 2025.9.6 2025-09-12 22:58:26 +10:00
Pasha Sviderski bc078472d4 feat: add -y|--yes flag for 'us deploy' to auto-confirm deployment plan 2025-09-12 22:52:06 +10:00
Pasha Sviderski 8a273dbf63 chore: rename cluster -> context option in commands for consistency 2025-09-11 20:21:07 +10:00
Pasha Sviderski 001acfc6a5 docs: remove --watch flag from compose file for deploying caddy 2025-09-10 17:32:15 +10:00
225 changed files with 15421 additions and 2034 deletions
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.23.2"
go-version: "1.25.1"
- name: Install dependencies
run: |
@@ -40,7 +40,7 @@ jobs:
run: |
make ucind-image
make test
timeout-minutes: 10
timeout-minutes: 15
check-protobuf:
runs-on: ${{ matrix.os }}
@@ -57,7 +57,7 @@ jobs:
- name: Install Mise
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
with:
version: "2025.6.5"
version: "2025.9.6"
env:
GITHUB_TOKEN: ${{ github.token }}
+2 -2
View File
@@ -23,12 +23,12 @@ jobs:
- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.23.2"
go-version: "1.25.1"
- name: golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
with:
version: v2.2.2
version: v2.4.0
- name: Format code
run: |
+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
+76 -26
View File
@@ -1,47 +1,97 @@
[tools."aqua:vektra/mockery"]
[[tools."aqua:vektra/mockery"]]
version = "3.5.3"
backend = "aqua:vektra/mockery"
[tools."aqua:vektra/mockery".checksums]
"mockery_3.5.3_Darwin_arm64.tar.gz" = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
"mockery_3.5.3_Linux_x86_64.tar.gz" = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
[tools."aqua:vektra/mockery".platforms.linux-x64]
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
size = 5498859
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
[tools.go]
version = "1.23.10"
[tools."aqua:vektra/mockery".platforms.macos-arm64]
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
size = 5238877
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz"
[[tools.go]]
version = "1.25.1"
backend = "core:go"
[tools.go.checksums]
"go1.23.10.darwin-arm64.tar.gz" = "sha256:25c64bfa8a8fd8e7f62fb54afa4354af8409a4bb2358c2699a1003b733e6fce5"
"go1.23.10.linux-amd64.tar.gz" = "sha256:535f9f81802499f2a7dbfa70abb8fda3793725fcc29460f719815f6e10b5fd60"
[tools.go.platforms.linux-x64]
checksum = "sha256:7716a0d940a0f6ae8e1f3b3f4f36299dc53e31b16840dbd171254312c41ca12e"
size = 59660846
url = "https://dl.google.com/go/go1.25.1.linux-amd64.tar.gz"
[tools.golangci-lint]
version = "2.2.2"
[tools.go.platforms.macos-arm64]
checksum = "sha256:68deebb214f39d542e518ebb0598a406ab1b5a22bba8ec9ade9f55fb4dd94a6c"
size = 57906702
url = "https://dl.google.com/go/go1.25.1.darwin-arm64.tar.gz"
[[tools.golangci-lint]]
version = "2.4.0"
backend = "aqua:golangci/golangci-lint"
[tools.golangci-lint.checksums]
"golangci-lint-2.2.2-darwin-arm64.tar.gz" = "sha256:d84d94d042c0d495fd1746f3d18948a75de163b17a14e8de3ef840928dd2df74"
"golangci-lint-2.2.2-linux-amd64.tar.gz" = "sha256:c27fbde948a87d326feacd21df2f61a9c54dbd2e3bfa185c0a1cd6917a6f964f"
[tools.golangci-lint.platforms.linux-x64]
checksum = "sha256:fae792524c04424c0ac369f5b8076f04b45cf29fc945a370e55d369a8dc11840"
size = 14126779
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-linux-amd64.tar.gz"
[tools.protoc]
[tools.golangci-lint.platforms.macos-arm64]
checksum = "sha256:cd4dd53fa09b6646baff5fd22b8c64d91db02c21c7496df27992d75d34feec59"
size = 13542526
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-darwin-arm64.tar.gz"
[[tools.node]]
version = "22.20.0"
backend = "core:node"
[tools.node.platforms.linux-x64]
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
size = 56645685
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
[tools.node.platforms.macos-arm64]
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
size = 49838299
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-darwin-arm64.tar.gz"
[[tools.protoc]]
version = "27.3"
backend = "aqua:protocolbuffers/protobuf/protoc"
[tools.protoc.checksums]
"protoc-27.3-linux-x86_64.zip" = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
"protoc-27.3-osx-aarch_64.zip" = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
[tools.protoc.platforms.linux-x64]
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
size = 3235873
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
[tools.protoc-gen-go]
[tools.protoc.platforms.macos-arm64]
checksum = "blake3:650c2494c003e9fddd6f4164a0a36e31da51ae7e1e5208da8cb560a4fe886fcd"
size = 2372763
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-aarch_64.zip"
[[tools.protoc-gen-go]]
version = "1.34.2"
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
[tools.protoc-gen-go.checksums]
"protoc-gen-go.v1.34.2.darwin.arm64.tar.gz" = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
"protoc-gen-go.v1.34.2.linux.amd64.tar.gz" = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
[tools.protoc-gen-go.platforms.linux-x64]
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
size = 1848331
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
[tools.protoc-gen-go-grpc]
[tools.protoc-gen-go.platforms.macos-arm64]
checksum = "blake3:c237256a7f718ac4ccdc037ee30025a0b7c3f79300d7831ec59d76d782443fd5"
size = 1784028
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
[[tools.protoc-gen-go-grpc]]
version = "1.5.1"
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
[tools.protoc-gen-go-grpc.checksums]
"protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz" = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
"protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz" = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
[tools.protoc-gen-go-grpc.platforms.linux-x64]
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
size = 4859466
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
[tools.protoc-gen-go-grpc.platforms.macos-arm64]
checksum = "blake3:3c668207565d23ea2cf006d699207e3a804ebb929a534bd38a3b4ff4e941e00c"
size = 4565944
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz"
+3 -2
View File
@@ -3,8 +3,9 @@ experimental = true
[tools]
"aqua:vektra/mockery" = "3.5.3"
go = "1.23"
golangci-lint = "2.2.2"
go = "1.25.1"
golangci-lint = "2.4.0"
node = "22.20.0"
protoc = "27.3"
protoc-gen-go = "1.34.2"
protoc-gen-go-grpc = "1.5.1"
+19 -5
View File
@@ -28,7 +28,7 @@ This document provides comprehensive information about the Uncloud project for A
- **Docker Compose compatibility**: Uses familiar Docker Compose format
- **Zero-downtime deployments**: Rolling updates without service interruption
- **Automatic HTTPS**: Caddy reverse proxy with Let's Encrypt integration
- **Managed DNS**: Free `*.cluster.uncloud.run` subdomains via Uncloud DNS service
- **Managed DNS**: Free `*.xxxxxx.uncld.dev` subdomains via Uncloud DNS service
- **Cross-machine scaling**: Run containers across multiple machines
### 🔧 Developer Experience
@@ -88,6 +88,10 @@ This document provides comprehensive information about the Uncloud project for A
- **`scripts/`**: Installation and utility scripts
- **`test/`**: Test suites and test infrastructure
- **`website/`**: Documentation website (Docusaurus)
- `landing/`: Landing page
- `docs/`: User documentation
- **`misc/`**: Design documents and guides
## Key Technologies
@@ -184,9 +188,11 @@ uc context use <name> # Switch context
### Testing
- Unit tests alongside source files (`*_test.go`)
- Integration tests in `test/e2e/`
- Test fixtures in `test/fixtures/`
- Test files and locations
- Unit tests alongside source files (`*_test.go`)
- Integration tests in `test/e2e/`
- Test fixtures in `test/fixtures/`
- Use table driven tests whenever possible
### Dependencies
@@ -233,7 +239,7 @@ uc context use <name> # Switch context
- `internal/machine/machine.go`: Core machine management
- `pkg/api/`: Public API definitions
- `misc/design.md`: Architecture and design philosophy
- `README.md`: User-facing documentation
- `README.md`: Repository README
### Configuration Files
@@ -242,3 +248,11 @@ uc context use <name> # Switch context
- `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.
## Documentation
Instructions when generating documentation:
- Use conversational language write as if you were speaking to a friend.
- Keep sentences simple, optimize for clarity and understanding.
- Place the subject before the action whenever possible. Example: prefer “The function loads data” over “Data is loaded by the function.”
+2 -2
View File
@@ -1,6 +1,6 @@
ARG ALPINE_VERSION=3.20.3
FROM golang:1.23.2-alpine AS uncloudd
FROM golang:1.25.1-alpine AS uncloudd
ARG TARGETOS
ARG TARGETARCH
@@ -13,7 +13,7 @@ COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd cmd/uncloudd/main.go
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd ./cmd/uncloudd
FROM alpine:${ALPINE_VERSION} AS corrosion-download
+1 -1
View File
@@ -38,7 +38,7 @@ demo-reset:
.PHONY: ucind-cluster
ucind-cluster:
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
.PHONY: proto
proto:
+17 -10
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>Docker simplicity. Multi-machine power</strong></p>
<p>
<a href="https://uncloud.run/docs"><img src="https://img.shields.io/badge/Docs-blue.svg?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation"></a>
@@ -25,12 +25,14 @@ complexity of Kubernetes.
## ✨ Features
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment,
regardless of location or provider.
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
need to learn a new bespoke DSL.
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
soon.
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Build and push your Docker images directly to
your machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
@@ -38,18 +40,13 @@ complexity of Kubernetes.
* **No control plane**: Fully decentralised design eliminates single points of failure and reduces operational overhead.
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
model and troubleshooting.
* **Managed DNS**: Automatic DNS records `*.<id>.cluster.uncloud.run` for services with public access via managed
* **Managed DNS**: Automatic DNS records `*.xxxxxx.uncld.dev` for services with public access via managed
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
Encrypt.
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
### 🚀 Coming soon
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Push your Docker images directly to your
machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
## 🎬 Quick demo
The screenshot below demonstrates how I use Uncloud to deploy https://uncloud.run website to 2 remote machines from
@@ -67,6 +64,12 @@ on-premises in just a couple minutes.
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
</a>
<br>
<br>
> **📚 Want more examples?** Check out the [**uncloud-recipes**](https://github.com/psviderski/uncloud-recipes)
> repository for community recipes and templates for deploying popular services on Uncloud.
## 💫 Why Uncloud?
Modern cloud platforms like Heroku and Render offer amazing developer experiences but at a premium price. Traditional
@@ -334,6 +337,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">
+67 -33
View File
@@ -10,68 +10,102 @@ import (
"github.com/spf13/cobra"
)
// NewBuildCommand creates a new command to build services from a Compose file.
type buildOptions struct {
cli.BuildServicesOptions
files []string
profiles []string
}
// NewBuildCommand creates a new command to build images for services from a Compose file.
func NewBuildCommand() *cobra.Command {
opts := cli.BuildOptions{}
opts := buildOptions{}
cmd := &cobra.Command{
Use: "build [FLAGS] [SERVICE...]",
Short: "Build services from a Compose file.",
Long: `Build images for services from a Compose file using local Docker.
By default, built images remain on the local Docker host. Use --push to upload them
to cluster machines or --push-registry to upload them to external registries.`,
Example: ` # Build all services that have a build section in compose.yaml.
uc build
# Build specific services that have a build section.
uc build web api
# Build services and push images to all cluster machines or service x-machines if specified.
uc build --push
# Build services and push images to specific machines.
uc build --push -m machine1,machine2
# Build services and push images to external registries (e.g., Docker Hub).
uc build --push-registry
# Build services with build arguments, pull newer base images before building, and don't use cache.
uc build --build-arg NODE_VERSION=24 --build-arg ENV=production --no-cache --pull`,
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
if len(args) > 0 {
opts.Services = args
}
return runBuild(cmd.Context(), uncli, opts)
},
GroupID: "service",
}
cmd.Flags().StringSliceVarP(&opts.Files, "file", "f", nil,
"One or more Compose files to build (default compose.yaml)")
cmd.Flags().StringSliceVarP(&opts.Profiles, "profile", "p", nil,
cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil,
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
cmd.Flags().BoolVar(&opts.Check, "check", false,
"Check the build configuration for services without building them.")
cmd.Flags().BoolVar(&opts.Deps, "deps", false,
"Also build services declared as dependencies of the selected services.")
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
"One or more Compose files to build. (default compose.yaml)")
cmd.Flags().StringSliceVarP(&opts.Machines, "machine", "m", nil,
"Machine names or IDs to push the built images to (requires --push).\n"+
"Can be specified multiple times or as a comma-separated list. (default is all machines or x-machines)")
cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false,
"Do not use cache when building images.")
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
"One or more Compose profiles to enable.")
cmd.Flags().BoolVarP(&opts.Push, "push", "P", false,
"Push built images to the registry after building. (default false)")
cmd.Flags().BoolVarP(&opts.NoCache, "no-cache", "n", false,
"Do not use cache when building images. (default false)")
cmd.Flags().BoolVar(&opts.Pull, "pull", false,
"Always attempt to pull newer versions of base images before building.")
cmd.Flags().BoolVar(&opts.PushCluster, "push", false,
"Upload the built images to cluster machines after building.\n"+
"Use --machine to specify which machines. (default is all machines)")
cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false,
"Upload the built images to external registries (e.g., Docker Hub) after building.")
return cmd
}
// TODO: deduplicate with a similar functino for deploy options
// projectOpts returns the project options for the Compose file(s).
func projectOptsFromBuildOpts(opts cli.BuildOptions) []composecli.ProjectOptionsFn {
projectOpts := []composecli.ProjectOptionsFn{}
if len(opts.Profiles) > 0 {
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.Profiles...))
// runBuild parses the Compose file(s) and builds the images for selected services.
func runBuild(ctx context.Context, uncli *cli.CLI, opts buildOptions) error {
// Validate push flags.
if opts.PushCluster && opts.PushRegistry {
return fmt.Errorf("cannot specify both --push and --push-registry: choose one push target")
}
return projectOpts
}
machines := cli.ExpandCommaSeparatedValues(opts.Machines)
// Special handling for an explicit "all" keyword to push to all machines.
if len(machines) == 1 && machines[0] == "all" {
machines = nil
}
opts.Machines = machines
// runBuild parses the Compose file(s), builds the services, and pushes them if requested.
func runBuild(ctx context.Context, uncli *cli.CLI, opts cli.BuildOptions) error {
projectOpts := projectOptsFromBuildOpts(opts)
project, err := compose.LoadProject(ctx, opts.Files, projectOpts...)
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
if err != nil {
return fmt.Errorf("load compose file(s): %w", err)
}
if len(opts.Services) > 0 {
project, err = project.WithSelectedServices(opts.Services)
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps)
if err != nil {
return fmt.Errorf("select services: %w", err)
return fmt.Errorf("determine services to build: %w", err)
}
}
servicesToBuild := cli.GetServicesThatNeedBuild(project)
if len(servicesToBuild) == 0 {
fmt.Println("No services to build.")
return nil
}
return cli.BuildServices(ctx, servicesToBuild, opts)
return uncli.BuildServices(ctx, project, opts.BuildServicesOptions)
}
+2 -8
View File
@@ -7,14 +7,12 @@ import (
"github.com/alecthomas/chroma/v2/quick"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type configOptions struct {
machine string
noColor bool
context string
}
func NewConfigCommand() *cobra.Command {
@@ -34,16 +32,12 @@ func NewConfigCommand() *cobra.Command {
"Name or ID of the machine to get the configuration from. (default is connected machine)")
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
"Disable syntax highlighting for the output.")
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -51,7 +45,7 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
if opts.machine != "" {
// If a specific machine is requested, use it to get the Caddy configuration.
ctx, _, err = api.ProxyMachinesContext(ctx, clusterClient, []string{opts.machine})
ctx, _, err = clusterClient.ProxyMachinesContext(ctx, []string{opts.machine})
if err != nil {
return err
}
+3 -8
View File
@@ -22,7 +22,6 @@ type deployOptions struct {
caddyfile string
image string
machines []string
context string
}
func NewDeployCommand() *cobra.Command {
@@ -44,12 +43,8 @@ func NewDeployCommand() *cobra.Command {
cmd.Flags().StringVar(&opts.image, "image", "",
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Machine names to deploy to. Can be specified multiple times or as a comma-separated "+
"list of machine names. (default is all machines)")
cmd.Flags().StringVarP(
&opts.context, "context", "c", "",
"Name of the cluster context to deploy to. (default is the current context)",
)
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
"list. (default is all machines)")
return cmd
}
@@ -64,7 +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)
}
+78
View File
@@ -0,0 +1,78 @@
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",
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 -8
View File
@@ -12,8 +12,6 @@ import (
)
func NewListCommand() *cobra.Command {
var clusterContext string
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
@@ -24,11 +22,6 @@ func NewListCommand() *cobra.Command {
},
}
cmd.Flags().StringVarP(
&clusterContext, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
@@ -51,7 +44,7 @@ func list(uncli *cli.CLI) error {
for _, name := range contextNames {
current := ""
if name == currentContext {
current = "*"
current = ""
}
connCount := len(uncli.Config.Contexts[name].Connections)
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
+1
View File
@@ -19,6 +19,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(
NewListCommand(),
NewUseCommand(),
NewConnectionCommand(),
)
return cmd
+76 -31
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
@@ -17,13 +18,14 @@ import (
)
type deployOptions struct {
cli.BuildServicesOptions
files []string
profiles []string
services []string
noBuild bool
recreate bool
context string
yes bool
}
// NewDeployCommand creates a new command to deploy services from a Compose file.
@@ -33,26 +35,34 @@ func NewDeployCommand() *cobra.Command {
Use: "deploy [FLAGS] [SERVICE...]",
Short: "Deploy services from a Compose file.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
if len(args) > 0 {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.services = args
}
return runDeploy(cmd.Context(), uncli, opts)
},
GroupID: "service",
}
cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil,
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
cmd.Flags().BoolVar(&opts.BuildServicesOptions.Pull, "build-pull", false,
"Always attempt to pull newer versions of base images before building service images.")
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
"One or more Compose files to deploy services from. (default compose.yaml)")
cmd.Flags().BoolVar(&opts.noBuild, "no-build", false,
"Do not build new images before deploying services.")
cmd.Flags().BoolVar(&opts.BuildServicesOptions.NoCache, "no-cache", false,
"Do not use cache when building images.")
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
"One or more Compose profiles to enable.")
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context to deploy to (default is the current context)")
cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false,
"Do not build images before deploying services. (default false)")
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
"Recreate containers even if their configuration and image haven't changed.")
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
// Could be useful to test a new version on a subset of machines before rolling out to all.
@@ -60,22 +70,9 @@ func NewDeployCommand() *cobra.Command {
return cmd
}
// projectOpts returns the project options for the Compose file(s).
func projectOpts(opts deployOptions) []composecli.ProjectOptionsFn {
projectOpts := []composecli.ProjectOptionsFn{}
if len(opts.profiles) > 0 {
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.profiles...))
}
return projectOpts
}
// runDeploy parses the Compose file(s) and deploys the services.
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
projectOpts := projectOpts(opts)
project, err := compose.LoadProject(ctx, opts.files, projectOpts...)
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
if err != nil {
return fmt.Errorf("load compose file(s): %w", err)
}
@@ -88,29 +85,69 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
}
}
servicesToBuild := cli.GetServicesThatNeedBuild(project)
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.services, false)
if err != nil {
return fmt.Errorf("determine services to build: %w", err)
}
if len(servicesToBuild) > 0 {
if opts.noBuild {
fmt.Println("Not building services as requested.")
} else {
buildOpts := cli.BuildOptions{
Push: true,
NoCache: false,
}
// Build service images without pushing them to cluster yet to not connect to the cluster twice.
opts.BuildServicesOptions.Deps = true // build dependencies as deploy includes them by default
opts.BuildServicesOptions.Services = opts.services
if err := cli.BuildServices(ctx, servicesToBuild, buildOpts); err != nil {
if err = uncli.BuildServices(ctx, project, opts.BuildServicesOptions); err != nil {
return fmt.Errorf("build services: %w", err)
}
}
fmt.Println()
}
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
if len(servicesToBuild) > 0 && !opts.noBuild {
// Push built service images to cluster machines one at a time.
var errs []error
for _, s := range servicesToBuild {
if s.Image == "" {
// Skip services without an image name (shouldn't happen for services with build config).
continue
}
// Push to the specified x-machines or to *all* cluster machines if not specified.
var pushOpts client.PushImageOptions
if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
pushOpts.Machines = machines
}
if len(pushOpts.Machines) == 0 {
pushOpts.AllMachines = true
}
boldStyle := lipgloss.NewStyle().Bold(true)
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
}
return nil
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
// Collect errors to try pushing all images.
if err != nil {
errs = append(errs, err)
}
}
if err = errors.Join(errs...); err != nil {
return err
}
fmt.Println()
}
var strategy deploy.Strategy
if opts.recreate {
strategy = &deploy.RollingStrategy{ForceRecreate: true}
@@ -130,12 +167,19 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
return nil
}
fmt.Println("Deployment plan:")
fmt.Println(lipgloss.NewStyle().Bold(true).Render("Deployment plan"))
if err = printPlan(ctx, clusterClient, plan); err != nil {
return fmt.Errorf("print deployment plan: %w", err)
}
fmt.Println()
// Ask for plan confirmation before proceeding with the deployment unless auto-confirmed with --yes.
if !opts.yes {
if !cli.IsStdinTerminal() {
return errors.New("cannot ask to confirm deployment plan in non-interactive mode, " +
"use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm")
}
confirmed, err := cli.Confirm()
if err != nil {
return fmt.Errorf("confirm deployment: %w", err)
@@ -144,6 +188,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
fmt.Println("Cancelled. No changes were made.")
return nil
}
}
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err := plan.Execute(ctx, clusterClient); err != nil {
+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)
}
+307
View File
@@ -0,0 +1,307 @@
package image
import (
"context"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/containerd/platforms"
"github.com/docker/docker/api/types/image"
"github.com/docker/go-units"
"github.com/muesli/termenv"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type listOptions struct {
machines []string
nameFilter string
}
func NewListCommand() *cobra.Command {
opts := listOptions{}
cmd := &cobra.Command{
Use: "ls [REPO:[TAG]]",
Aliases: []string{"list"},
Short: "List images on machines in the cluster.",
Long: "List images on machines in the cluster. By default, on all machines. Optionally filter by image name.",
Example: ` # List all images on all machines.
uc image ls
# List images on specific machine.
uc image ls -m machine1
# List images on multiple machines.
uc image ls -m machine1,machine2
# List images filtered by name (with any tag) on all machines.
uc image ls myapp
# List images filtered by name pattern on specific machine.
uc image ls "myapp:1.*" -m machine1`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.nameFilter = args[0]
}
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
"(default is include all machines)")
return cmd
}
// imageRow represents a single image with its metadata for display.
type imageRow struct {
id string
name string
platforms string
createdHuman string
createdUnix int64
size string
inUse string
store string
machine string
}
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
// Get all machines to create ID to name mapping.
allMachines, err := clusterClient.ListMachines(ctx, nil)
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machineIDToName := make(map[string]string)
for _, machineMember := range allMachines {
if machineMember.Machine != nil && machineMember.Machine.Id != "" && machineMember.Machine.Name != "" {
machineIDToName[machineMember.Machine.Id] = machineMember.Machine.Name
}
}
machines := cli.ExpandCommaSeparatedValues(opts.machines)
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
Machines: machines,
Name: opts.nameFilter,
})
if err != nil {
return fmt.Errorf("list images: %w", err)
}
// Collect all images from all machines.
var rows []imageRow
for _, machineImages := range clusterImages {
// Get machine name for better readability.
machineName := machineImages.Metadata.Machine
if m := allMachines.FindByNameOrID(machineName); m != nil {
machineName = m.Machine.Name
}
store := "docker"
if machineImages.ContainerdStore {
store = "containerd"
}
// Process each image for this machine.
for _, img := range machineImages.Images {
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
name := "<none>"
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
name = img.RepoTags[0]
}
imgPlatforms, _ := imagePlatforms(img)
formattedPlatforms := formatPlatforms(imgPlatforms)
created := ""
createdAt := time.Unix(img.Created, 0)
if !createdAt.IsZero() {
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
}
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
inUse := "-"
if img.Containers != -1 { // -1 means the info is not available.
if img.Containers > 0 {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
} else {
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
}
}
rows = append(rows, imageRow{
id: id,
name: name,
platforms: formattedPlatforms,
createdHuman: created,
createdUnix: img.Created,
size: size,
inUse: inUse,
store: store,
machine: machineName,
})
}
}
if len(rows) == 0 {
if opts.nameFilter != "" {
fmt.Printf("No images matching '%s' found.\n", opts.nameFilter)
} else {
fmt.Println("No images found.")
}
return nil
}
// Sort images by name, then by machine name.
sort.Slice(rows, func(i, j int) bool {
if rows[i].name != rows[j].name {
return rows[i].name < rows[j].name
}
return rows[i].machine < rows[j].machine
})
// Print the images in a table format.
fmt.Println(formatImageTable(rows))
return nil
}
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
func imagePlatforms(img image.Summary) ([]string, bool) {
var formattedPlatforms []string
multiPlatform := false
for _, m := range img.Manifests {
if m.Kind != image.ManifestKindImage || !m.Available {
continue
}
if m.ID != img.ID {
// There is an image manifest that has digest different from the main image digest.
// This means the image manifest is an index or a manifest list (multi-platform image).
multiPlatform = true
}
formattedPlatforms = append(formattedPlatforms, platforms.Format(m.ImageData.Platform))
}
slices.Sort(formattedPlatforms)
return formattedPlatforms, multiPlatform
}
func formatPlatforms(platforms []string) string {
if len(platforms) == 0 {
return "-"
}
platformStyle := lipgloss.NewStyle().
BorderForeground(lipgloss.Color("152")).
Foreground(lipgloss.Color("0")).
Background(lipgloss.Color("152"))
// Use fancy pill borders only if the output is a terminal with color support.
if lipgloss.ColorProfile() != termenv.Ascii {
platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true)
}
styledPlatforms := make([]string, len(platforms))
for i, p := range platforms {
styledPlatforms[i] = platformStyle.Render(p)
}
return strings.Join(styledPlatforms, " ")
}
func formatImageTable(rows []imageRow) string {
columns := []struct {
name string
hide bool
}{
{name: "IMAGE ID"},
{name: "NAME"},
{name: "PLATFORMS"},
{name: "CREATED"},
{name: "SIZE"},
{name: "IN USE"},
{name: "STORE"},
{name: "MACHINE"},
}
// Hide the "IN USE" column if none of the images have that info available.
inUseInfoAvailable := slices.ContainsFunc(rows, func(r imageRow) bool {
return r.inUse != "-"
})
if !inUseInfoAvailable {
// Hide "IN USE" column.
columns[5].hide = true
}
t := table.New().
// Remove the default border.
Border(lipgloss.Border{}).
BorderTop(false).
BorderBottom(false).
BorderLeft(false).
BorderRight(false).
BorderHeader(false).
BorderColumn(false).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
}
// Regular style for data rows with padding.
return lipgloss.NewStyle().PaddingRight(3)
})
var headers []string
for _, col := range columns {
if !col.hide {
headers = append(headers, col.name)
}
}
t.Headers(headers...)
for _, row := range rows {
values := []string{
row.id,
row.name,
row.platforms,
row.createdHuman,
row.size,
row.inUse,
row.store,
row.machine,
}
var filteredValues []string
for i, v := range values {
if !columns[i].hide {
filteredValues = append(filteredValues, v)
}
}
t.Row(filteredValues...)
}
return t.String()
}
+94
View File
@@ -0,0 +1,94 @@
package image
import (
"context"
"fmt"
"github.com/containerd/platforms"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra"
)
type pushOptions struct {
image string
machines []string
platform string
}
func NewPushCommand() *cobra.Command {
opts := pushOptions{}
cmd := &cobra.Command{
Use: "push IMAGE",
Short: "Upload a local Docker image to the cluster.",
Long: `Upload a local Docker image to the cluster transferring only the missing layers.
The image is uploaded to all cluster machines (default) or the specified machine(s).`,
Example: ` # Push image to all machines in the cluster.
uc image push myapp:latest
# Push image to specific machine.
uc image push myapp:latest -m machine1
# Push image to multiple machines.
uc image push myapp:latest -m machine1,machine2,machine3
# Push a specific platform of a multi-platform image.
uc image push myapp:latest --platform linux/amd64`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.image = args[0]
return push(cmd.Context(), uncli, opts)
},
}
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
"Machine names or IDs to push the image to. Can be specified multiple times or as a comma-separated list. "+
"(default is all machines)")
cmd.Flags().StringVar(
&opts.platform, "platform", "",
"Push a specific platform of a multi-platform image (e.g., linux/amd64, linux/arm64).\n"+
"Local Docker must be configured to use containerd image store to support multi-platform images.",
)
return cmd
}
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
clusterClient, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
machines := cli.ExpandCommaSeparatedValues(opts.machines)
pushOpts := client.PushImageOptions{}
// Special handling for an explicit "all" keyword to push to all machines.
if len(machines) == 1 && machines[0] == "all" {
pushOpts.AllMachines = true
} else if len(machines) > 0 {
pushOpts.Machines = machines
} else {
// Default is to push to all machines in the cluster.
pushOpts.AllMachines = true
}
if opts.platform != "" {
p, err := platforms.Parse(opts.platform)
if err != nil {
return fmt.Errorf("invalid platform '%s': %w", opts.platform, err)
}
pushOpts.Platform = &p
}
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err = clusterClient.PushImage(ctx, opts.image, pushOpts); err != nil {
return fmt.Errorf("push image to cluster: %w", err)
}
return nil
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", opts.image))
}
+19
View File
@@ -0,0 +1,19 @@
package image
import (
"github.com/spf13/cobra"
)
func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "image",
Short: "Manage images on machines in the cluster.",
}
cmd.AddCommand(
NewListCommand(),
NewPushCommand(),
)
return cmd
}
+19
View File
@@ -0,0 +1,19 @@
package main
import (
"strings"
"github.com/psviderski/uncloud/cmd/uncloud/image"
"github.com/spf13/cobra"
)
// NewImagesCommand returns the 'image ls' command modified to work as 'images'.
func NewImagesCommand() *cobra.Command {
listCmd := image.NewListCommand()
listCmd.Use = "images [IMAGE]"
// Remove 'list' alias since this command is already an alias.
listCmd.Aliases = nil
listCmd.Example = strings.ReplaceAll(listCmd.Example, "uc image ls", "uc images")
return listCmd
}
+21 -7
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/netip"
"strings"
"time"
"github.com/cenkalti/backoff/v4"
@@ -22,9 +23,9 @@ import (
type addOptions struct {
name string
noCaddy bool
noInstall bool
publicIP string
sshKey string
context string
version string
}
@@ -33,11 +34,22 @@ func NewAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add [USER@]HOST[:PORT]",
Short: "Add a remote machine to a cluster.",
Long: `Add a new machine to an existing Uncloud cluster.
Connection methods:
ssh://user@host - Use built-in SSH library (default, no prefix required)
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
user, host, port, err := config.SSHDestination(args[0]).Parse()
// Determine if SSH CLI needs to be used and strip scheme
destination := args[0]
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh://")
user, host, port, err := config.SSHDestination(destination).Parse()
if err != nil {
return fmt.Errorf("parse remote machine: %w", err)
}
@@ -46,6 +58,7 @@ func NewAddCommand() *cobra.Command {
Host: host,
Port: port,
KeyPath: opts.sshKey,
UseSSHCLI: useSSHCLI,
}
return add(cmd.Context(), uncli, remoteMachine, opts)
@@ -56,6 +69,11 @@ func NewAddCommand() *cobra.Command {
&opts.noCaddy, "no-caddy", false,
"Don't deploy Caddy reverse proxy service to the machine.",
)
cmd.Flags().BoolVar(
&opts.noInstall, "no-install", false,
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
"Assumes they're already installed and running.",
)
cmd.Flags().StringVar(
&opts.publicIP, "public-ip", "auto",
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
@@ -70,10 +88,6 @@ 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)",
)
return cmd
}
@@ -94,10 +108,10 @@ 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,
})
if err != nil {
+43 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/netip"
"strings"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
@@ -22,6 +23,7 @@ type initOptions struct {
network string
noCaddy bool
noDNS bool
noInstall bool
publicIP string
sshKey string
version string
@@ -31,9 +33,26 @@ type initOptions struct {
func NewInitCommand() *cobra.Command {
opts := initOptions{}
cmd := &cobra.Command{
Use: "init [USER@HOST:PORT]",
Use: "init [schema://]USER@HOST[:PORT]",
Short: "Initialise a new cluster with a remote machine as the first member.",
// TODO: include usage examples of initialising a remote machine.
Long: `Initialise a new cluster by setting up a remote machine as the first member.
This command creates a new context in your Uncloud config to manage the cluster.
Connection methods:
ssh://user@host - Use built-in SSH library (default, no prefix required)
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
Example: ` # Initialise a new cluster with default settings.
uc machine init root@<your-server-ip>
# Initialise with a context name 'prod' in the Uncloud config (~/.config/uncloud/config.yaml) and machine name 'vps1'.
uc machine init root@<your-server-ip> -c prod -n vps1
# Initialise with a non-root user and custom SSH port and key.
uc machine init ubuntu@<your-server-ip>:2222 -i ~/.ssh/mykey
# Initialise without Caddy (no reverse proxy) and without an automatically managed domain name (xxxxxx.uncld.dev).
# You can deploy Caddy with 'uc caddy deploy' and reserve a domain with 'uc dns reserve' later.
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
// TODO: support initialising a cluster on the local machine.
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -41,7 +60,13 @@ func NewInitCommand() *cobra.Command {
var remoteMachine *cli.RemoteMachine
if len(args) > 0 {
user, host, port, err := config.SSHDestination(args[0]).Parse()
// Determine if SSH CLI is requested and strip scheme
destination := args[0]
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh+cli://")
destination = strings.TrimPrefix(destination, "ssh://")
user, host, port, err := config.SSHDestination(destination).Parse()
if err != nil {
return fmt.Errorf("parse remote machine: %w", err)
}
@@ -50,6 +75,7 @@ func NewInitCommand() *cobra.Command {
Host: host,
Port: port,
KeyPath: opts.sshKey,
UseSSHCLI: useSSHCLI,
}
}
@@ -68,11 +94,16 @@ func NewInitCommand() *cobra.Command {
)
cmd.Flags().BoolVar(
&opts.noCaddy, "no-caddy", false,
"Don't deploy Caddy reverse proxy service to the machine.",
"Don't deploy Caddy reverse proxy service to the machine. You can deploy it later with 'uc caddy deploy'.",
)
cmd.Flags().BoolVar(
&opts.noDNS, "no-dns", false,
"Don't reserve a cluster domain in Uncloud DNS.",
"Don't reserve a cluster domain in Uncloud DNS. You can reserve it later with 'uc dns reserve'.",
)
cmd.Flags().BoolVar(
&opts.noInstall, "no-install", false,
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
"Assumes they're already installed and running.",
)
cmd.Flags().StringVar(
&opts.publicIP, "public-ip", "auto",
@@ -90,13 +121,18 @@ func NewInitCommand() *cobra.Command {
)
cmd.Flags().StringVarP(
&opts.context, "context", "c", cli.DefaultContextName,
"Name of the created context for the initialised cluster in the Uncloud config.",
"Name of the new context to be created in the Uncloud config to manage the cluster.",
)
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)
@@ -121,6 +157,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
Network: netPrefix,
PublicIP: publicIP,
RemoteMachine: remoteMachine,
SkipInstall: opts.noInstall,
Version: opts.version,
})
if err != nil {
+6 -11
View File
@@ -14,25 +14,20 @@ import (
)
func NewListCommand() *cobra.Command {
var contextName string
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List machines in a cluster.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, contextName)
return list(cmd.Context(), uncli)
},
}
cmd.Flags().StringVarP(
&contextName, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
client, err := uncli.ConnectCluster(ctx, clusterName)
func list(ctx context.Context, uncli *cli.CLI) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -46,7 +41,7 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
// Print the list of machines in a table format.
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
// Print header.
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS"); err != nil {
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS\tMACHINE ID"); err != nil {
return fmt.Errorf("write header: %w", err)
}
// Print rows.
@@ -68,8 +63,8 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
}
if _, err = fmt.Fprintf(
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
strings.Join(endpoints, ", "),
tw, "%s\t%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
strings.Join(endpoints, ", "), member.Machine.Id,
); err != nil {
return fmt.Errorf("write row: %w", err)
}
+3 -8
View File
@@ -9,7 +9,6 @@ import (
)
func NewRenameCommand() *cobra.Command {
var contextName string
cmd := &cobra.Command{
Use: "rename OLD_NAME NEW_NAME",
Short: "Rename a machine in the cluster.",
@@ -20,18 +19,14 @@ configuration including network settings, public IP, and cluster membership.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return rename(cmd.Context(), uncli, contextName, args[0], args[1])
return rename(cmd.Context(), uncli, args[0], args[1])
},
}
cmd.Flags().StringVarP(
&contextName, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func rename(ctx context.Context, uncli *cli.CLI, contextName, oldName, newName string) error {
client, err := uncli.ConnectCluster(ctx, contextName)
func rename(ctx context.Context, uncli *cli.CLI, oldName, newName string) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return err
}
+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
}
+63 -17
View File
@@ -6,15 +6,18 @@ 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"
"github.com/psviderski/uncloud/cmd/uncloud/image"
"github.com/psviderski/uncloud/cmd/uncloud/machine"
"github.com/psviderski/uncloud/cmd/uncloud/service"
"github.com/psviderski/uncloud/cmd/uncloud/volume"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/fs"
"github.com/psviderski/uncloud/internal/log"
"github.com/psviderski/uncloud/internal/version"
"github.com/spf13/cobra"
)
@@ -22,17 +25,24 @@ import (
type globalOptions struct {
configPath string
connect string
context string
}
func main() {
log.InitLoggerFromEnv()
opts := globalOptions{}
cmd := &cobra.Command{
Use: "uc",
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
Short: "A CLI tool for managing Uncloud resources such as machines, services, and volumes.",
Version: version.String(),
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
cli.BindEnvToFlag(cmd, "context", "UNCLOUD_CONTEXT")
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
var conn *config.MachineConnection
if opts.connect != "" {
if strings.HasPrefix(opts.connect, "tcp://") {
@@ -43,11 +53,17 @@ func main() {
conn = &config.MachineConnection{
TCP: &addrPort,
}
} else {
dest := opts.connect
if strings.HasPrefix(dest, "ssh://") {
dest = dest[len("ssh://"):]
} else if strings.HasPrefix(opts.connect, "ssh+cli://") {
dest := opts.connect[len("ssh+cli://"):]
conn = &config.MachineConnection{
SSHCLI: config.SSHDestination(dest),
}
} else if strings.HasPrefix(opts.connect, "unix://") {
conn = &config.MachineConnection{
Unix: opts.connect[len("unix://"):],
}
} else {
dest := strings.TrimPrefix(opts.connect, "ssh://")
conn = &config.MachineConnection{
SSH: config.SSHDestination(dest),
}
@@ -55,7 +71,7 @@ func main() {
}
configPath := fs.ExpandHomeDir(opts.configPath)
uncli, err := cli.New(configPath, conn)
uncli, err := cli.New(configPath, conn, opts.context)
if err != nil {
return fmt.Errorf("initialise CLI: %w", err)
}
@@ -65,28 +81,58 @@ func main() {
}
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
"Connect to a remote cluster machine without using the Uncloud configuration file.\n"+
"Format: [ssh://]user@host[:port] or tcp://host:port")
// TODO: allow to override using UNCLOUD_CONFIG env var.
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
"Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock")
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
"Path to the Uncloud configuration file.")
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
// TODO: make --context a global flag and pass it as a value of the command context.
cmd.PersistentFlags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]")
// Set custom help function to show links to docs and Discord only for the root 'uc' command.
defaultHelpFunc := cmd.HelpFunc()
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
defaultHelpFunc(c, args)
// Only show links for the root 'uc' command.
if c.Name() == "uc" {
urlStyle := lipgloss.NewStyle().
Underline(true).
Foreground(lipgloss.Color("12")) // light blue
fmt.Fprintln(c.OutOrStdout())
fmt.Fprintf(c.OutOrStdout(), "Learn more about Uncloud: %s\n",
urlStyle.Render("https://uncloud.run/docs"))
fmt.Fprintf(c.OutOrStdout(), "Join our Discord community: %s\n",
urlStyle.Render("https://uncloud.run/discord"))
}
})
cmd.AddGroup(&cobra.Group{
ID: "service",
Title: "Deploy and manage services:",
})
cmd.AddCommand(
NewBuildCommand(),
NewDeployCommand(),
NewDocsCommand(),
NewBuildCommand(),
NewImagesCommand(),
NewPsCommand(),
caddy.NewRootCommand(),
cmdcontext.NewRootCommand(),
dns.NewRootCommand(),
image.NewRootCommand(),
machine.NewRootCommand(),
service.NewRootCommand(),
service.NewInspectCommand(),
service.NewListCommand(),
service.NewRmCommand(),
service.NewRunCommand(),
service.NewScaleCommand(),
service.NewExecCommand("service"),
service.NewInspectCommand("service"),
service.NewListCommand("service"),
service.NewLogsCommand("service"),
service.NewRmCommand("service"),
service.NewRunCommand("service"),
service.NewScaleCommand("service"),
service.NewStartCommand("service"),
service.NewStopCommand("service"),
volume.NewRootCommand(),
)
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 {
machineName := "unknown"
// 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")
}
if msc.Metadata != nil && msc.Metadata.Error != "" {
client.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
msc.Metadata.Error))
continue
}
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
}
}
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)
}
}
+128
View File
@@ -0,0 +1,128 @@
package service
import (
"context"
"fmt"
"os"
"github.com/docker/cli/cli/streams"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type execCliOptions struct {
detach bool
interactive bool
noTty bool
containerId string
}
var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
func NewExecCommand(groupID string) *cobra.Command {
opts := execCliOptions{}
execCmd := &cobra.Command{
Use: "exec [OPTIONS] SERVICE [COMMAND ARGS...]",
Short: "Execute a command in a running service container.",
Long: `Execute a command (interactive shell by default) in a running container within a service.
If the service has multiple replicas and no container ID is specified, the command will be executed in a random container.
`,
Example: `
# Start an interactive shell ("bash" or "sh" will be tried by default)
uc exec web-service
# Start an interactive shell with explicit command
uc exec web-service /bin/zsh
# List files in the specific container of the service; --container accepts full ID or a (unique) prefix
uc exec --container d792e web-service ls -la
# Pipe input to a command inside the service container
cat backup.sql | uc exec -T db-service psql -U postgres mydb
# Run a task in the background (detached mode)
uc exec -d web-service /scripts/cleanup.sh`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
serviceName := args[0]
command := args[1:]
if len(command) == 0 {
command = DEFAULT_COMMAND
}
return runExec(cmd.Context(), uncli, serviceName, command, opts)
},
GroupID: groupID,
}
execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background")
execCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", !cli.IsStdoutTerminal(),
"Disable pseudo-TTY allocation. By default 'uc exec' allocates a TTY when connected to a terminal.")
// Keep "-i" and "-t" flags hidden for compatibility with docker exec
execCmd.Flags().BoolVarP(&opts.interactive, "interactive", "i", true, "Keep STDIN open even if not attached")
execCmd.Flags().MarkHidden("interactive")
execCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY")
execCmd.Flags().MarkHidden("tty")
// Common flags
execCmd.Flags().StringVar(&opts.containerId, "container", "",
"ID of the container to exec into. Accepts full ID or a unique prefix "+
"(default is the random container of the service)")
// This tells Cobra that all flags must come before positional arguments, so that
// commands with their own flags can be handled correctly.
execCmd.Flags().SetInterspersed(false)
return execCmd
}
func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error {
if !opts.detach {
// Check if we're trying to attach to a TTY from a non-TTY client, e.g.
// when doing an 'cmd | uc exec ...'
stdin := streams.NewIn(os.Stdin)
// TODO: this logic/behavior mirrors docker-compose, but we can be smarter about it and detect TTY dynamically
if err := stdin.CheckTty(opts.interactive, !opts.noTty); err != nil {
return fmt.Errorf("check TTY: %w; use -T option to disable TTY allocation", err)
}
}
client, err := uncli.ConnectClusterWithOptions(ctx, cli.ConnectOptions{
ShowProgress: false,
})
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer client.Close()
execConfig := api.ExecOptions{
Command: command,
AttachStdin: opts.interactive,
Tty: !opts.noTty,
Detach: opts.detach,
}
if !opts.detach {
execConfig.AttachStdout = true
execConfig.AttachStderr = true
}
exitCode, err := client.ExecContainer(ctx, serviceName, opts.containerId, execConfig)
if err != nil {
return fmt.Errorf("exec container: %w", err)
}
// For non-detached mode, exit with the same code as the executed command
if !opts.detach {
if exitCode != 0 {
os.Exit(exitCode)
}
}
return nil
}
+29 -15
View File
@@ -4,21 +4,22 @@ import (
"context"
"fmt"
"os"
"slices"
"text/tabwriter"
"time"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type inspectOptions struct {
service string
cluster string
}
func NewInspectCommand() *cobra.Command {
func NewInspectCommand(groupID string) *cobra.Command {
opts := inspectOptions{}
cmd := &cobra.Command{
Use: "inspect SERVICE",
@@ -29,16 +30,13 @@ func NewInspectCommand() *cobra.Command {
opts.service = args[0]
return inspect(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
cmd.Flags().StringVarP(
&opts.cluster, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
client, err := uncli.ConnectCluster(ctx, opts.cluster)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -58,23 +56,31 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
machinesNamesByID[m.Machine.Id] = m.Machine.Name
}
fmt.Printf("ID: %s\n", svc.ID)
fmt.Printf("Service ID: %s\n", svc.ID)
fmt.Printf("Name: %s\n", svc.Name)
fmt.Printf("Mode: %s\n", svc.Mode)
fmt.Println()
// Parse created times for sorting and display.
createdTimes := make(map[string]time.Time, len(svc.Containers))
for _, ctr := range svc.Containers {
createdTimes[ctr.Container.ID], _ = time.Parse(time.RFC3339Nano, ctr.Container.Created)
}
// Sort containers by created time (newest first).
slices.SortFunc(svc.Containers, func(a, b api.MachineServiceContainer) int {
return createdTimes[b.Container.ID].Compare(createdTimes[a.Container.ID])
})
// Print the list of containers in a table format.
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tMACHINE"); err != nil {
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tIP ADDRESS\tMACHINE"); err != nil {
return fmt.Errorf("write header: %w", err)
}
now := time.Now().UTC()
for _, ctr := range svc.Containers {
createdAt, err := time.Parse(time.RFC3339Nano, ctr.Container.Created)
if err != nil {
return fmt.Errorf("parse created time: %w", err)
}
created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
machine := machinesNamesByID[ctr.MachineID]
if machine == "" {
@@ -85,13 +91,21 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
return fmt.Errorf("get human state: %w", err)
}
ip := ctr.Container.UncloudNetworkIP()
ipStr := ""
// The container might not have an IP if it's not running or uses the host network.
if ip.IsValid() {
ipStr = ip.String()
}
_, err = fmt.Fprintf(
tw,
"%s\t%s\t%s\t%s\t%s\n",
"%s\t%s\t%s\t%s\t%s\t%s\n",
stringid.TruncateID(ctr.Container.ID),
ctr.Container.Config.Image,
created,
state,
ipStr,
machine,
)
if err != nil {
+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))
}
}
+27 -20
View File
@@ -4,34 +4,31 @@ import (
"context"
"fmt"
"os"
"slices"
"strings"
"text/tabwriter"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
func NewListCommand() *cobra.Command {
// TODO(lhf): rename to context
var cluster string
func NewListCommand(groupID string) *cobra.Command {
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List services.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, cluster)
return list(cmd.Context(), uncli)
},
GroupID: groupID,
}
cmd.Flags().StringVarP(
&cluster, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
client, err := uncli.ConnectCluster(ctx, clusterName)
func list(ctx context.Context, uncli *cli.CLI) error {
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -42,15 +39,15 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
return fmt.Errorf("list services: %w", err)
}
serviceNames := make(map[string]struct{}, len(services))
// Sort services by name.
haveDuplicateNames := false
for _, svc := range services {
if _, exists := serviceNames[svc.Name]; exists {
slices.SortFunc(services, func(a, b api.Service) int {
if a.Name == b.Name {
haveDuplicateNames = true
break
}
serviceNames[svc.Name] = struct{}{}
return strings.Compare(a.ID, b.ID)
}
return strings.Compare(a.Name, b.Name)
})
// Print the list of services in a table format.
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
@@ -61,19 +58,29 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
return fmt.Errorf("write header: %w", err)
}
}
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tENDPOINTS"); err != nil {
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tIMAGE\tENDPOINTS"); err != nil {
return fmt.Errorf("write header: %w", err)
}
for _, s := range services {
endpointsSlice := s.Endpoints()
endpoints := strings.Join(endpointsSlice, ", ")
images := strings.Join(s.Images(), ", ")
endpoints := strings.Join(s.Endpoints(), ", ")
// If no endpoints from ports, check if the service uses custom Caddy config.
if endpoints == "" {
for _, ctr := range s.Containers {
if ctr.Container.ServiceSpec.CaddyConfig() != "" {
endpoints = "(custom Caddy config)"
}
}
}
if haveDuplicateNames {
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
return fmt.Errorf("write row: %w", err)
}
}
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", s.Name, s.Mode, len(s.Containers), endpoints); err != nil {
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n",
s.Name, s.Mode, len(s.Containers), images, endpoints); err != nil {
return fmt.Errorf("write row: %w", err)
}
}
+9 -8
View File
@@ -11,31 +11,32 @@ import (
type rmOptions struct {
services []string
cluster string
}
func NewRmCommand() *cobra.Command {
func NewRmCommand(groupID string) *cobra.Command {
opts := rmOptions{}
cmd := &cobra.Command{
Use: "rm SERVICE [SERVICE...]",
Aliases: []string{"remove", "delete"},
Short: "Remove one or more services.",
Long: `Remove one or more services.
The volumes used by the services are preserved and should be removed separately
with 'uc volume rm'. Anonymous Docker volumes (automatically created from VOLUME
directives in image Dockerfiles) are automatically removed with their containers.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
opts.services = args
return rm(cmd.Context(), uncli, opts)
},
GroupID: groupID,
}
cmd.Flags().StringVarP(
&opts.cluster, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
}
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
client, err := uncli.ConnectCluster(ctx, opts.cluster)
client, err := uncli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
@@ -50,5 +51,5 @@ func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
}, uncli.ProgressOut(), "Removing service "+s)
}
return nil
return err
}
+10 -6
View File
@@ -8,14 +8,18 @@ func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "service",
Aliases: []string{"svc"},
Short: "Manage services in an Uncloud cluster.",
Short: "Manage services in the cluster.",
}
cmd.AddCommand(
NewInspectCommand(),
NewListCommand(),
NewRmCommand(),
NewRunCommand(),
NewScaleCommand(),
NewExecCommand(""),
NewInspectCommand(""),
NewListCommand(""),
NewLogsCommand(""),
NewRmCommand(""),
NewRunCommand(""),
NewScaleCommand(""),
NewStartCommand(""),
NewStopCommand(""),
)
return cmd
}
+3 -9
View File
@@ -34,11 +34,9 @@ type runOptions struct {
replicas uint
user string
volumes []string
cluster 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.cluster, "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.cluster)
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
cluster 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.cluster, "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.cluster)
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(),
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"context"
"fmt"
"io"
"net"
"os"
"github.com/psviderski/uncloud/internal/machine"
"github.com/spf13/cobra"
)
func newDialStdioCommand() *cobra.Command {
var socketPath string
cmd := &cobra.Command{
Use: "dial-stdio",
Short: "Proxy stdin/stdout to the Uncloud API socket",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDialStdio(cmd.Context(), socketPath, os.Stdin, os.Stdout)
},
}
cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultUncloudSockPath,
"Path to the Uncloud API socket")
return cmd
}
// halfReadCloser is the read side of a half-duplex connection.
type halfReadCloser interface {
io.Reader
CloseRead() error
}
// halfWriteCloser is the write side of a half-duplex connection.
type halfWriteCloser interface {
io.Writer
CloseWrite() error
}
// halfReadCloserWrapper wraps an io.ReadCloser to implement halfReadCloser.
type halfReadCloserWrapper struct {
io.ReadCloser
}
func (x *halfReadCloserWrapper) CloseRead() error {
return x.Close()
}
// halfWriteCloserWrapper wraps an io.WriteCloser to implement halfWriteCloser.
type halfWriteCloserWrapper struct {
io.WriteCloser
}
func (x *halfWriteCloserWrapper) CloseWrite() error {
return x.Close()
}
func runDialStdio(ctx context.Context, socketPath string, stdin io.Reader, stdout io.Writer) error {
// Connect to the unix socket.
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "unix", socketPath)
if err != nil {
return fmt.Errorf("connect to socket %q: %w", socketPath, err)
}
defer conn.Close()
// Wrap stdin/stdout to support half-closing.
var stdinCloser halfReadCloser
if c, ok := stdin.(halfReadCloser); ok {
stdinCloser = c
} else if c, ok := stdin.(io.ReadCloser); ok {
stdinCloser = &halfReadCloserWrapper{c}
}
var stdoutCloser halfWriteCloser
if c, ok := stdout.(halfWriteCloser); ok {
stdoutCloser = c
} else if c, ok := stdout.(io.WriteCloser); ok {
stdoutCloser = &halfWriteCloserWrapper{c}
}
// Copy data bidirectionally between stdin/stdout and the socket.
stdin2socket := make(chan error, 1)
socket2stdout := make(chan error, 1)
// Copy from stdin to socket.
go func() {
_, err := io.Copy(conn, stdin)
stdin2socket <- err
// Close write side of connection after stdin is done.
if unixConn, ok := conn.(*net.UnixConn); ok {
unixConn.CloseWrite()
}
if stdinCloser != nil {
stdinCloser.CloseRead()
}
}()
// Copy from socket to stdout.
go func() {
_, err := io.Copy(stdout, conn)
socket2stdout <- err
// Close read side of connection after socket is done sending.
if unixConn, ok := conn.(*net.UnixConn); ok {
unixConn.CloseRead()
}
if stdoutCloser != nil {
stdoutCloser.CloseWrite()
}
}()
select {
case err = <-stdin2socket:
if err != nil {
return err
}
// wait for stdout
err = <-socket2stdout
case err = <-socket2stdout:
// return immediately, matching Docker's approach
// (stdin is never closed when TTY)
}
return err
}
+3
View File
@@ -42,6 +42,9 @@ func main() {
"Directory for storing persistent machine state")
_ = cmd.MarkFlagDirname("data-dir")
// Add dial-stdio subcommand.
cmd.AddCommand(newDialStdioCommand())
// ctx is canceled when the daemon command is interrupted.
ctx, cancel := context.WithCancel(context.Background())
+184 -89
View File
@@ -1,25 +1,30 @@
module github.com/psviderski/uncloud
go 1.23.0
go 1.25
require (
github.com/BurntSushi/toml v1.4.0
github.com/BurntSushi/toml v1.5.0
github.com/Masterminds/semver v1.5.0
github.com/Masterminds/squirrel v1.5.4
github.com/alecthomas/chroma/v2 v2.20.0
github.com/caddyserver/caddy/v2 v2.8.4
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/lipgloss v0.13.0
github.com/compose-spec/compose-go/v2 v2.4.5
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
github.com/containerd/platforms v1.0.0-rc.1
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/deckarep/golang-set/v2 v2.8.0
github.com/dgraph-io/badger/v3 v3.2103.5
github.com/distribution/reference v0.6.0
github.com/docker/cli v27.5.0+incompatible
github.com/docker/compose/v2 v2.31.0
github.com/docker/docker v27.4.0-rc.2+incompatible
github.com/docker/go-connections v0.5.0
github.com/docker/cli v28.5.0+incompatible
github.com/docker/compose/v2 v2.40.0
github.com/docker/docker v28.5.0+incompatible
github.com/docker/go-connections v0.6.0
github.com/docker/go-units v0.5.0
github.com/goccy/go-yaml v1.17.1
github.com/google/go-cmp v0.7.0
@@ -37,45 +42,66 @@ require (
github.com/lmittmann/tint v1.0.5
github.com/miekg/dns v1.1.65
github.com/mitchellh/mapstructure v1.5.0
github.com/moby/term v0.5.0
github.com/moby/term v0.5.2
github.com/muesli/termenv v0.16.0
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.0
github.com/opencontainers/image-spec v1.1.1
github.com/psviderski/unregistry v0.4.1
github.com/siderolabs/discovery-api v0.1.4
github.com/siderolabs/discovery-client v0.1.9
github.com/siderolabs/grpc-proxy v0.5.1
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
github.com/vishvananda/netlink v1.3.0
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
github.com/vishvananda/netlink v1.3.1
go.uber.org/zap v1.27.0
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.33.0
golang.org/x/net v0.35.0
golang.org/x/sync v0.11.0
golang.org/x/sys v0.31.0
golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0
golang.org/x/sync v0.17.0
golang.org/x/sys v0.36.0
golang.org/x/term v0.34.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
google.golang.org/grpc v1.68.1
google.golang.org/protobuf v1.36.3
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a
google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.9
modernc.org/sqlite v1.36.3
)
require (
cloud.google.com/go/kms v1.19.0 // indirect
cloud.google.com/go/longrunning v0.6.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/Microsoft/hcsshim v0.13.0 // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/aws/smithy-go v1.20.3 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bgentry/speakeasy v0.2.0 // indirect
@@ -85,68 +111,86 @@ require (
github.com/catppuccin/go v0.2.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbles v0.20.0 // indirect
github.com/charmbracelet/bubbletea v1.1.1 // indirect
github.com/charmbracelet/x/ansi v0.3.2 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/cloudflare/cfssl v1.6.4 // indirect
github.com/containerd/console v1.0.4 // indirect
github.com/containerd/containerd v1.7.24 // indirect
github.com/containerd/containerd/api v1.7.19 // indirect
github.com/containerd/continuity v0.4.4 // indirect
github.com/containerd/errdefs v0.3.0 // indirect
github.com/containerd/cgroups/v3 v3.0.5 // indirect
github.com/containerd/console v1.0.5 // indirect
github.com/containerd/containerd/api v1.9.0 // indirect
github.com/containerd/containerd/v2 v2.1.4 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/fifo v1.1.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/containerd/plugin v1.0.0 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/containerd/ttrpc v1.2.5 // indirect
github.com/containerd/typeurl/v2 v2.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/containerd/ttrpc v1.2.7 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/dgraph-io/badger v1.6.2 // indirect
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/distribution/v3 v3.0.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/buildx v0.18.0 // indirect
github.com/docker/buildx v0.29.1 // indirect
github.com/docker/cli-docs-tool v0.10.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/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
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/go-kit/kit v0.13.0 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/glog v1.2.5 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/cel-go v0.20.1 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/handlers v1.5.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-msgpack v0.5.5 // indirect
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -154,13 +198,15 @@ require (
github.com/hashicorp/go-syslog v1.0.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/arc/v2 v2.0.7 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/logutils v1.0.0 // indirect
github.com/hashicorp/mdns v1.0.5 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/in-toto/in-toto-golang v0.5.0 // indirect
github.com/in-toto/in-toto-golang v0.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/go-block-format v0.2.0 // indirect
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
@@ -178,8 +224,12 @@ require (
github.com/jackc/pgtype v1.14.0 // indirect
github.com/jackc/pgx/v4 v4.18.3 // indirect
github.com/jbenet/goprocess v0.1.4 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@@ -189,6 +239,7 @@ require (
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
github.com/libp2p/go-msgio v0.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -208,19 +259,26 @@ require (
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/buildkit v0.17.2 // indirect
github.com/moby/buildkit v0.25.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/capability v0.4.0 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/sys/symlink v0.3.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/multiformats/go-base32 v0.1.0 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect
github.com/multiformats/go-multiaddr v0.13.0 // indirect
@@ -231,94 +289,131 @@ require (
github.com/multiformats/go-multistream v0.5.0 // indirect
github.com/multiformats/go-varint v0.0.7 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
github.com/opencontainers/runtime-spec v1.2.1 // indirect
github.com/opencontainers/selinux v1.12.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pires/go-proxyproto v0.7.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/polydawn/refmt v0.89.0 // indirect
github.com/posener/complete v1.2.3 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.61.0 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.48.2 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
github.com/redis/go-redis/v9 v9.7.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.6.0 // indirect
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/siderolabs/gen v0.4.8 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/slackhq/nebula v1.6.1 // indirect
github.com/smallstep/certificates v0.26.1 // indirect
github.com/smallstep/nosql v0.6.1 // indirect
github.com/smallstep/pkcs7 v0.0.0-20231024181729-3b98ecc1ca81 // indirect
github.com/smallstep/pkcs7 v0.1.1 // indirect
github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
github.com/smallstep/truststore v0.13.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c // indirect
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect
github.com/tonistiigi/fsutil v0.0.0-20250605211040-586307ad452f // indirect
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
github.com/urfave/cli v1.22.16 // indirect
github.com/vbatts/tar-split v0.11.6 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/urfave/cli v1.22.17 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zclconf/go-cty v1.17.0 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.etcd.io/bbolt v1.3.11 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
go.opentelemetry.io/otel/log v0.8.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.step.sm/cli-utils v0.9.0 // indirect
go.step.sm/crypto v0.45.0 // indirect
go.step.sm/linkedca v0.20.1 // indirect
go.uber.org/automaxprocs v1.5.3 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap/exp v0.2.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.30.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
howett.net/plist v1.0.0 // indirect
k8s.io/api v0.32.3 // indirect
k8s.io/apimachinery v0.32.3 // indirect
k8s.io/client-go v0.32.3 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
lukechampine.com/blake3 v1.3.0 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
tags.cncf.io/container-device-interface v1.0.1 // indirect
)
+447 -259
View File
File diff suppressed because it is too large Load Diff
+176 -153
View File
@@ -2,175 +2,198 @@ package cli
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/charmbracelet/lipgloss"
composetypes "github.com/compose-spec/compose-go/v2/types"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/docker/registry"
"github.com/moby/term"
mapset "github.com/deckarep/golang-set/v2"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/flags"
composeapi "github.com/docker/compose/v2/pkg/api"
composev2 "github.com/docker/compose/v2/pkg/compose"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose"
)
type BuildOptions struct {
Files []string
Profiles []string
Services []string
Push bool
// BuildServicesOptions contains options for building services in a Compose project.
type BuildServicesOptions struct {
// BuildArgs sets build-time variables for services. Used in Dockerfiles that declare variables with ARG.
BuildArgs []string
// Check the build configuration for services without building them.
Check bool
// Deps enables to also build services declared as dependencies of the selected Services.
Deps bool
// NoCache disables the use of cache when building images.
NoCache bool
// Pull attempts to pull newer versions of the base images before building.
Pull bool
// Services specifies which services to build. If empty, all services with a build config are built.
Services []string
// Push targets are mutually exclusive.
// PushCluster uploads the built images to cluster machines after building.
PushCluster bool
// PushRegistry uploads the built images to external registries after building.
PushRegistry bool
// Cluster-specific options (only used if PushCluster is true).
// Machines is a list of machine names or IDs to push the image to. If empty, images are pushed to all machines.
Machines []string
}
// GetServicesThatNeedBuild returns a map of services that require building
func GetServicesThatNeedBuild(project *composetypes.Project) map[string]composetypes.ServiceConfig {
servicesToBuild := make(map[string]composetypes.ServiceConfig, len(project.Services))
for serviceName, service := range project.Services {
if service.Build == nil {
// BuildServices builds images for services in the Compose project.
func (cli *CLI) BuildServices(ctx context.Context, project *composetypes.Project, opts BuildServicesOptions) error {
// Validate push targets.
if opts.PushCluster && opts.PushRegistry {
return fmt.Errorf("cannot specify both PushCluster and PushRegistry: choose one push target")
}
// Build service images using Compose implementation.
dockerCli, err := command.NewDockerCli()
if err != nil {
return fmt.Errorf("create docker client: %w", err)
}
// Initialise the Docker CLI with default options.
if err = dockerCli.Initialize(flags.NewClientOptions()); err != nil {
return fmt.Errorf("initialise docker client: %w", err)
}
composeService := composev2.NewComposeService(dockerCli)
buildOpts := composeapi.BuildOptions{
Args: composetypes.NewMappingWithEquals(opts.BuildArgs),
Check: opts.Check,
Deps: opts.Deps,
NoCache: opts.NoCache,
Pull: opts.Pull,
Push: opts.PushRegistry,
Services: opts.Services,
}
if err = composeService.Build(ctx, project, buildOpts); err != nil {
return err
}
if !opts.PushCluster {
return nil
}
// Push built service images to cluster machines.
builtServices, err := ServicesThatNeedBuild(project, opts.Services, opts.Deps)
if err != nil {
return fmt.Errorf("determine built services: %w", err)
}
if len(builtServices) == 0 {
// No services were built, nothing to push.
return nil
}
// Add a line break after the build output.
fmt.Fprintln(cli.ProgressOut())
clusterClient, err := cli.ConnectCluster(ctx)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer clusterClient.Close()
// Push one service image at a time.
var errs []error
for _, s := range builtServices {
if s.Image == "" {
// Skip services without an image name (shouldn't happen for services with build config).
continue
}
servicesToBuild[serviceName] = service
}
return servicesToBuild
}
// BuildServices builds the services defined in the provided map.
func BuildServices(ctx context.Context, servicesToBuild map[string]composetypes.ServiceConfig, opts BuildOptions) error {
fmt.Println("Building services...")
// Push to the specified machines falling back to service x-machines.
// If none specified, push to *all* cluster machines.
var pushOpts client.PushImageOptions
// Init docker client (can be local or remote, depending on DOCKER_HOST environment variable)
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
if err != nil {
return err
}
defer dockerCli.Close()
serviceImages := make(map[string]string, len(servicesToBuild))
// Build the services using the local docker client and compose libraries
for _, service := range servicesToBuild {
fmt.Printf("Building service: %s\n", service.Name)
imageName, err := buildSingleService(ctx, dockerCli, service, opts)
if err != nil {
return fmt.Errorf("build service %s: %w", service.Name, err)
}
serviceImages[service.Name] = imageName
}
fmt.Printf("Service images are built.\n")
if opts.Push {
err = pushServiceImages(ctx, dockerCli, serviceImages)
if len(opts.Machines) > 0 {
pushOpts.Machines = opts.Machines
} else if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
pushOpts.Machines = machines
}
return err
}
// buildSingleService builds a single service using the Docker client and Compose libraries.
func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, service composetypes.ServiceConfig, opts BuildOptions) (string, error) {
if service.Build == nil {
return "", fmt.Errorf("service %s has no build configuration", service.Name)
}
if service.Image == "" {
return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet", service.Name)
if len(pushOpts.Machines) == 0 {
pushOpts.AllMachines = true
}
buildContextPath := service.Build.Context
imageName := service.Image
// Create a tar archive of the build context
buildContext, err := archive.TarWithOptions(buildContextPath, &archive.TarOptions{})
if err != nil {
return "", fmt.Errorf("failed to create build context for service %s: %w", service.Name, err)
}
buildOptions := types.ImageBuildOptions{
// TODO: Support Dockerfiles outside the build context
// See https://github.com/docker/compose/blob/cf89fd1aa1328d5af77658ccc5a1e1b29981ae80/pkg/compose/build_classic.go#L92
Dockerfile: service.Build.Dockerfile,
Tags: []string{imageName},
Remove: true, // Remove intermediate containers
NoCache: opts.NoCache,
}
buildResponse, err := dockerCli.ImageBuild(ctx, buildContext, buildOptions)
if err != nil {
return "", fmt.Errorf("failed to build image for service %s: %w", service.Name, err)
}
defer buildResponse.Body.Close()
// Display the build response
fd, isTerminal := term.GetFdInfo(os.Stdout)
if err := jsonmessage.DisplayJSONMessagesStream(buildResponse.Body, os.Stdout, fd, isTerminal, nil); err != nil {
return "", fmt.Errorf("failed to display build response for service %s: %w", service.Name, err)
}
return imageName, nil
}
// pushSingleServiceImage pushes a single service image.
func pushSingleServiceImage(ctx context.Context, dockerCli *dockerclient.Client, serviceName string, imageName string) error {
ref, err := reference.ParseNormalizedNamed(imageName)
if err != nil {
return err
}
repoInfo, err := registry.ParseRepositoryInfo(ref)
if err != nil {
return err
}
registryKey := repoInfo.Index.Name
if repoInfo.Index.Official {
registryKey = registry.IndexServer
}
// Load the Docker config file with auth details, if available
configFile := config.LoadDefaultConfigFile(os.Stderr)
authConfig, err := configFile.GetAuthConfig(registryKey)
if err != nil {
return err
}
authJSON, err := json.Marshal(authConfig)
if err != nil {
return fmt.Errorf("failed to marshal auth config for registry %s: %w", registryKey, err)
}
authStr := base64.URLEncoding.EncodeToString(authJSON)
pushOptions := image.PushOptions{
RegistryAuth: authStr,
}
pushResponse, err := dockerCli.ImagePush(ctx, imageName, pushOptions)
if err != nil {
return fmt.Errorf("failed to push image %s: %w", imageName, err)
}
defer pushResponse.Close()
fmt.Printf("Pushing image %s for service %s...\n", imageName, serviceName)
fd, isTerminal := term.GetFdInfo(os.Stdout)
if err := jsonmessage.DisplayJSONMessagesStream(pushResponse, os.Stdout, fd, isTerminal, nil); err != nil {
return fmt.Errorf("failed to display push response for image %s: %w", imageName, err)
}
fmt.Printf("Image %s pushed successfully.\n", imageName)
return nil
}
// pushServiceImages pushes all built service images to the registry.
func pushServiceImages(ctx context.Context, dockerCli *dockerclient.Client, serviceImages map[string]string) error {
fmt.Printf("Pushing images...\n")
for serviceName, imageName := range serviceImages {
if err := pushSingleServiceImage(ctx, dockerCli, serviceName, imageName); err != nil {
return fmt.Errorf("push image for service %s: %w", serviceName, err)
}
boldStyle := lipgloss.NewStyle().Bold(true)
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
}
return nil
}, cli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
// Collect errors to try pushing all images.
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// ServicesThatNeedBuild returns a list of services that require building.
// deps indicates whether to include services that are dependencies of the selected services.
// Implementation is based on the logic from docker/compose/v2/pkg/compose/build.go.
func ServicesThatNeedBuild(
project *composetypes.Project, selectedServices []string, deps bool,
) ([]composetypes.ServiceConfig, error) {
servicesToBuild := make([]composetypes.ServiceConfig, 0, len(project.Services))
var policy composetypes.DependencyOption = composetypes.IgnoreDependencies
if deps {
policy = composetypes.IncludeDependencies
}
// Also include services used as build.additional_contexts with service: prefix.
selectedServices = includeAdditionalContextsServices(project, selectedServices)
// Some build dependencies we just introduced may not be enabled, enable them.
var err error
if project, err = project.WithServicesEnabled(selectedServices...); err != nil {
return nil, err
}
if project, err = project.WithSelectedServices(selectedServices); err != nil {
return nil, err
}
err = project.ForEachService(selectedServices, func(serviceName string, service *composetypes.ServiceConfig) error {
if service.Build != nil {
servicesToBuild = append(servicesToBuild, *service)
}
return nil
}, policy)
if err != nil {
return nil, err
}
return servicesToBuild, nil
}
// includeAdditionalContextsServices adds services referenced in build.additional_contexts to the list
// of selected services.
func includeAdditionalContextsServices(project *composetypes.Project, selectedServices []string) []string {
servicesWithDependencies := mapset.NewSet(selectedServices...)
for _, service := range selectedServices {
s, ok := project.Services[service]
if !ok {
s = project.DisabledServices[service]
}
if s.Build != nil {
for _, target := range s.Build.AdditionalContexts {
if name, found := strings.CutPrefix(target, composetypes.ServicePrefix); found {
servicesWithDependencies.Add(name)
}
}
}
}
if servicesWithDependencies.Cardinality() > len(selectedServices) {
return includeAdditionalContextsServices(project, servicesWithDependencies.ToSlice())
}
return servicesWithDependencies.ToSlice()
}
+111 -53
View File
@@ -2,7 +2,6 @@ package cli
import (
"context"
"errors"
"fmt"
"net/netip"
"os"
@@ -10,7 +9,6 @@ import (
"github.com/docker/cli/cli/streams"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/fs"
"github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/sshexec"
@@ -32,12 +30,14 @@ const (
type CLI struct {
Config *config.Config
conn *config.MachineConnection
contextOverride string
}
// New creates a new CLI instance with the given config path or remote machine connection.
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
// a cluster without creating a config.
func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
// If a non-empty context name is given, it will override the current default.
func New(configPath string, conn *config.MachineConnection, contextName string) (*CLI, error) {
if conn != nil {
return &CLI{conn: conn}, nil
}
@@ -49,6 +49,7 @@ func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
return &CLI{
Config: cfg,
contextOverride: contextName,
}, nil
}
@@ -70,11 +71,30 @@ 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) {
func (cli *CLI) ConnectCluster(ctx context.Context) (*client.Client, error) {
return cli.ConnectClusterWithOptions(ctx, ConnectOptions{
// Default to showing progress for CLI usage.
ShowProgress: true,
})
}
// ConnectClusterWithOptions connects to a cluster with the given options.
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
// If the CLI has an override context, it is used instead of the current default.
// Options are useful when using the CLI as a library where you may want to disable visual feedback.
func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, opts ConnectOptions) (*client.Client, error) {
if cli.conn != nil {
return connectCluster(ctx, *cli.conn)
return ConnectCluster(ctx, *cli.conn, opts)
}
if len(cli.Config.Contexts) == 0 {
@@ -84,6 +104,8 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client
cli.Config.Path(),
)
}
contextName := cli.contextOverride
if contextName == "" {
// If the cluster is not specified, use the current cluster if set.
if cli.Config.CurrentContext == "" {
@@ -116,38 +138,20 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client
)
}
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
conn := cfg.Connections[0]
c, err := connectCluster(ctx, conn)
if err != nil {
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
}
// Try each connection in order until one succeeds.
var lastErr error
for _, conn := range cfg.Connections {
c, err := ConnectCluster(ctx, conn, opts)
if err == nil {
return c, nil
}
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
if conn.SSH != "" {
user, host, port, err := conn.SSH.Parse()
if err != nil {
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
}
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
sshConfig := &connector.SSHConnectorConfig{
User: user,
Host: host,
Port: port,
KeyPath: keyPath,
}
return client.New(ctx, connector.NewSSHConnector(sshConfig))
} else if conn.TCP != nil && conn.TCP.IsValid() {
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
lastErr = err
}
return nil, errors.New("connection configuration is invalid")
return nil, fmt.Errorf("failed to connect to cluster context '%s': "+
"all connections (%d) in the Uncloud config (%s) failed; last error: %w",
contextName, len(cfg.Connections), cli.Config.Path(), lastErr)
}
type InitClusterOptions struct {
@@ -156,6 +160,7 @@ type InitClusterOptions struct {
Network netip.Prefix
PublicIP *netip.Addr
RemoteMachine *RemoteMachine
SkipInstall bool
Version string
}
@@ -166,7 +171,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) {
@@ -175,7 +180,7 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
return nil, err
}
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
if err != nil {
return nil, err
}
@@ -237,8 +242,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 {
@@ -274,10 +292,10 @@ func (cli *CLI) newContextName(name string) (string, error) {
}
type AddMachineOptions struct {
Context string
MachineName string
PublicIP *netip.Addr
RemoteMachine *RemoteMachine
SkipInstall bool
Version string
}
@@ -286,11 +304,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)
}
@@ -300,7 +315,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
}
}()
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
machineClient, err := provisionOrConnectRemoteMachine(ctx, opts.RemoteMachine, opts.SkipInstall, opts.Version)
if err != nil {
return nil, nil, err
}
@@ -404,11 +419,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 {
@@ -418,15 +443,45 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
return c, machineClient, nil
}
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and returns
// a machine API client to interact with the machine. The client should be closed after use by the caller.
// provisionOrConnectRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and
// returns a machine API client to interact with the machine. The client should be closed after use by the caller.
// The version parameter specifies the version of the Uncloud daemon to install. If empty, the latest version is used.
// If skipInstall is true, the installation step is skipped, and it is assumed that the Uncloud daemon and dependencies
// are already installed and running.
// The remoteMachine.SSHKeyPath could be updated to the default SSH key path if it is not set and the SSH agent
// authentication fails.
func provisionRemoteMachine(
ctx context.Context, remoteMachine *RemoteMachine, version string,
func provisionOrConnectRemoteMachine(
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
) (*client.Client, error) {
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
// Use SSH CLI
if remoteMachine.UseSSHCLI {
exec := sshexec.NewSSHCLIRemote(
remoteMachine.User,
remoteMachine.Host,
remoteMachine.Port,
remoteMachine.KeyPath,
)
if !skipInstall {
if err := provisionMachine(ctx, exec, version); err != nil {
return nil, fmt.Errorf("provision machine: %w", err)
}
}
sshConfig := &connector.SSHConnectorConfig{
User: remoteMachine.User,
Host: remoteMachine.Host,
Port: remoteMachine.Port,
KeyPath: remoteMachine.KeyPath,
}
machineClient, err := client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
if err != nil {
return nil, fmt.Errorf("connect to remote machine: %w", err)
}
return machineClient, nil
}
// Use Go SSH
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
// If the SSH connection using SSH agent fails and no key path is provided, try to use the default SSH key.
if err != nil && remoteMachine.KeyPath == "" {
@@ -441,14 +496,17 @@ func provisionRemoteMachine(
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
)
}
if !skipInstall {
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
exec := sshexec.NewRemote(sshClient)
// Install and run the Uncloud daemon and dependencies on the remote machine.
if err = provisionMachine(ctx, exec, version); err != nil {
return nil, fmt.Errorf("provision machine: %w", err)
}
}
var machineClient *client.Client
if remoteMachine.User == "root" {
if remoteMachine.User == "root" || skipInstall {
// Create a machine API client over the established SSH connection to the remote machine.
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
} else {
+44
View File
@@ -1,6 +1,8 @@
package config
import (
"errors"
"fmt"
"net"
"net/netip"
"strconv"
@@ -16,12 +18,54 @@ const (
type MachineConnection struct {
SSH SSHDestination `yaml:"ssh,omitempty"`
SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"`
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
// TCP is the address and port of the machine's API server.
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
TCP *netip.AddrPort `yaml:"tcp,omitempty"`
// Unix is the path to the machine's API unix socket.
Unix string `yaml:"unix,omitempty"`
Host string `yaml:"host,omitempty"`
PublicKey secret.Secret `yaml:"public_key,omitempty"`
MachineID string `yaml:"machine_id,omitempty"`
}
func (c MachineConnection) String() string {
if c.SSH != "" {
return "ssh://" + string(c.SSH)
} else if c.SSHCLI != "" {
return "ssh+cli://" + string(c.SSHCLI)
} else if c.TCP != nil && c.TCP.IsValid() {
return fmt.Sprintf("tcp://%s", c.TCP)
} else if c.Unix != "" {
return fmt.Sprintf("unix://%s", c.Unix)
}
return "unknown connection"
}
func (c *MachineConnection) Validate() error {
setCount := 0
if c.SSH != "" {
setCount++
}
if c.SSHCLI != "" {
setCount++
}
if c.TCP != nil && c.TCP.IsValid() {
setCount++
}
if c.Unix != "" {
setCount++
}
if setCount == 0 {
return errors.New("no connection method specified (ssh, ssh_cli, tcp, or unix required)")
}
if setCount > 1 {
return errors.New("only one connection method allowed per connection (ssh, ssh_cli, tcp, or unix)")
}
return nil
}
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
+205
View File
@@ -0,0 +1,205 @@
package config
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
)
func TestMachineConnection_String(t *testing.T) {
t.Parallel()
tests := []struct {
name string
conn MachineConnection
want string
}{
{
name: "ssh connection",
conn: MachineConnection{
SSH: "user@host.com",
},
want: "ssh://user@host.com",
},
{
name: "ssh connection with port",
conn: MachineConnection{
SSH: "user@host.com:2222",
},
want: "ssh://user@host.com:2222",
},
{
name: "ssh_cli connection",
conn: MachineConnection{
SSHCLI: "user@host.com",
},
want: "ssh+cli://user@host.com",
},
{
name: "ssh_cli connection with port",
conn: MachineConnection{
SSHCLI: "user@host.com:2222",
},
want: "ssh+cli://user@host.com:2222",
},
{
name: "tcp connection",
conn: MachineConnection{
TCP: func() *netip.AddrPort {
addr := netip.MustParseAddrPort("10.0.0.1:8080")
return &addr
}(),
},
want: "tcp://10.0.0.1:8080",
},
{
name: "unix connection",
conn: MachineConnection{
Unix: "/run/uncloud/uncloud.sock",
},
want: "unix:///run/uncloud/uncloud.sock",
},
{
name: "no connection",
conn: MachineConnection{},
want: "unknown connection",
},
{
name: "ssh connection with machine id",
conn: MachineConnection{
SSH: "user@host.com",
MachineID: "ed98b9f7575308c340263cd279e3b568",
},
// String() does not use MachineID; just verifying entry with it is valid
want: "ssh://user@host.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.conn.String()
assert.Equal(t, tt.want, got)
})
}
}
func TestMachineConnection_Validate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
conn MachineConnection
wantErr bool
errMsg string
}{
{
name: "ssh only - valid",
conn: MachineConnection{
SSH: "user@host",
},
wantErr: false,
},
{
name: "ssh_cli only - valid",
conn: MachineConnection{
SSHCLI: "user@host",
},
wantErr: false,
},
{
name: "tcp only - valid",
conn: MachineConnection{
TCP: func() *netip.AddrPort {
addr := netip.MustParseAddrPort("10.0.0.1:8080")
return &addr
}(),
},
wantErr: false,
},
{
name: "unix only - valid",
conn: MachineConnection{
Unix: "/path/to/socket",
},
wantErr: false,
},
{
name: "no connection method - error",
conn: MachineConnection{},
wantErr: true,
errMsg: "no connection method specified",
},
{
name: "ssh and ssh_cli - error",
conn: MachineConnection{
SSH: "user@host",
SSHCLI: "user@host",
},
wantErr: true,
errMsg: "only one connection method allowed",
},
{
name: "ssh and unix - error",
conn: MachineConnection{
SSH: "user@host",
Unix: "/path/to/socket",
},
wantErr: true,
errMsg: "only one connection method allowed",
},
{
name: "ssh and tcp - error",
conn: MachineConnection{
SSH: "user@host",
TCP: func() *netip.AddrPort {
addr := netip.MustParseAddrPort("10.0.0.1:8080")
return &addr
}(),
},
wantErr: true,
errMsg: "only one connection method allowed",
},
{
name: "ssh_cli and tcp - error",
conn: MachineConnection{
SSHCLI: "user@host",
TCP: func() *netip.AddrPort {
addr := netip.MustParseAddrPort("10.0.0.1:8080")
return &addr
}(),
},
wantErr: true,
errMsg: "only one connection method allowed",
},
{
name: "all three - error",
conn: MachineConnection{
SSH: "user@host",
SSHCLI: "user@host2",
TCP: func() *netip.AddrPort {
addr := netip.MustParseAddrPort("10.0.0.1:8080")
return &addr
}(),
},
wantErr: true,
errMsg: "only one connection method allowed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.conn.Validate()
if tt.wantErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
} else {
assert.NoError(t, err)
}
})
}
}
+18
View File
@@ -4,3 +4,21 @@ type Context struct {
Name string `yaml:"-"`
Connections []MachineConnection `yaml:"connections"`
}
func (c *Context) SetDefaultConnection(index int) {
// Do nothing if the index is out of range.
if index < 0 || index >= len(c.Connections) {
return
}
selected := c.Connections[index]
newConnections := make([]MachineConnection, 0, len(c.Connections))
newConnections = append(newConnections, selected)
for i, conn := range c.Connections {
if i != index {
newConnections = append(newConnections, conn)
}
}
c.Connections = newConnections
}
+212
View File
@@ -0,0 +1,212 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/psviderski/uncloud/internal/cli/config"
"github.com/psviderski/uncloud/internal/fs"
"github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/connector"
)
// ConnectOptions configures the behavior of cluster connection attempts.
type ConnectOptions struct {
// Whether to show connection progress spinner if stdout is a terminal or progress logs if not.
ShowProgress bool
}
func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts ConnectOptions) (*client.Client, error) {
if opts.ShowProgress {
return connectClusterWithProgress(ctx, conn)
}
return connectCluster(ctx, conn)
}
// connectClusterWithProgress connects to the cluster while displaying a progress spinner.
// If the stdout is not a terminal, it falls back to simple progress logs to stderr.
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
// If stdout is not a terminal, fall back to simple progress logs.
if !IsStdoutTerminal() {
fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
cli, err := connectCluster(ctx, conn)
if err != nil {
fmt.Fprintln(os.Stderr, "Connection failed:", err)
} else {
fmt.Fprintln(os.Stderr, "Connected to cluster.")
}
return cli, err
}
// Run the connection TUI model.
p := tea.NewProgram(newConnectModel(ctx, conn))
model, err := p.Run()
if err != nil {
return nil, fmt.Errorf("run connection TUI: %w", err)
}
m := model.(connectModel)
return m.result.client, m.result.err
}
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
// Determine which SSH type is configured
var sshDest config.SSHDestination
var useSSHCLI bool
// Validate connection configuration early to provide clear error messages.
if err := conn.Validate(); err != nil {
return nil, fmt.Errorf("invalid connection configuration: %w", err)
}
if conn.SSH != "" {
sshDest = conn.SSH
useSSHCLI = false
} else if conn.SSHCLI != "" {
sshDest = conn.SSHCLI
useSSHCLI = true
} else if conn.TCP != nil && conn.TCP.IsValid() {
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
} else if conn.Unix != "" {
return client.New(ctx, connector.NewUnixConnector(conn.Unix))
} else {
return nil, errors.New("connection configuration is invalid")
}
// Parse SSH destination and create config (shared for both types)
user, host, port, err := sshDest.Parse()
if err != nil {
return nil, fmt.Errorf("parse SSH connection %q: %w", sshDest, err)
}
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
sshConfig := &connector.SSHConnectorConfig{
User: user,
Host: host,
Port: port,
KeyPath: keyPath,
}
// Create appropriate connector based on type
if useSSHCLI {
return client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
}
return client.New(ctx, connector.NewSSHConnector(sshConfig))
}
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
type connectModel struct {
ctx context.Context
conn config.MachineConnection
spinner spinner.Model
// showSpinner controls whether the spinner is visible (delayed to avoid flashing).
showSpinner bool
// done indicates whether the connection attempt has completed (successfully or with error).
done bool
// result holds the result of the connection attempt.
result connectResultMsg
}
type connectResultMsg struct {
client *client.Client
err error
}
// showSpinnerMsg is sent after a delay to show the spinner.
type showSpinnerMsg struct{}
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
s := spinner.New()
s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // the same yellow as in compose progress
return connectModel{
ctx: ctx,
conn: conn,
spinner: s,
}
}
func (m connectModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
m.connect(),
m.delayShowSpinner(),
)
}
func (m connectModel) connect() tea.Cmd {
return func() tea.Msg {
cli, err := connectCluster(m.ctx, m.conn)
return connectResultMsg{
client: cli,
err: err,
}
}
}
// delayShowSpinner returns a command that sends a message to show the spinner after a delay.
// This avoids flashing the spinner if the connection is fast.
func (m connectModel) delayShowSpinner() tea.Cmd {
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
return showSpinnerMsg{}
})
}
func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
select {
case <-m.ctx.Done():
m.result.err = m.ctx.Err()
m.done = true
return m, tea.Quit
default:
}
switch msg := msg.(type) {
case connectResultMsg:
m.result = msg
m.done = true
return m, tea.Quit
case showSpinnerMsg:
// Only show spinner if connection hasn't completed yet.
if !m.done {
m.showSpinner = true
}
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.result.err = fmt.Errorf("connection cancelled")
m.done = true
return m, tea.Quit
}
}
return m, nil
}
func (m connectModel) View() string {
// Don't show anything if done or spinner not yet visible.
if m.done || !m.showSpinner {
return ""
}
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
return fmt.Sprintf("%s %s\n",
m.spinner.View(),
fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())),
)
}
+13
View File
@@ -1,7 +1,11 @@
package cli
import (
"log"
"os"
"strings"
"github.com/spf13/cobra"
)
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
@@ -21,3 +25,12 @@ func ExpandCommaSeparatedValues(values []string) []string {
return expanded
}
// BindEnvToFlag assigns the value of an environment variable to the given command flag if the flag has not been set.
func BindEnvToFlag(cmd *cobra.Command, flagName, envVar string) {
if value := os.Getenv(envVar); value != "" && !cmd.Flags().Changed(flagName) {
if err := cmd.Flags().Set(flagName, value); err != nil {
log.Fatalf("Failed to bind environment variable '%s' to flag '%s': %v", envVar, flagName, err)
}
}
}
+1
View File
@@ -25,6 +25,7 @@ type RemoteMachine struct {
Host string
Port int
KeyPath string
UseSSHCLI bool // indicates ssh+cli:// should be used
}
func installCmd(user string, version string) string {
+16 -1
View File
@@ -1,6 +1,11 @@
package cli
import "github.com/charmbracelet/huh"
import (
"os"
"github.com/charmbracelet/huh"
"golang.org/x/term"
)
func Confirm() (bool, error) {
var confirmed bool
@@ -21,3 +26,13 @@ func Confirm() (bool, error) {
return confirmed, nil
}
// IsStdinTerminal checks if the standard input is a terminal (TTY).
func IsStdinTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
// IsStdoutTerminal checks if the standard output is a terminal (TTY).
func IsStdoutTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
+5
View File
@@ -11,7 +11,12 @@ import (
"github.com/docker/docker/client"
)
type Client struct {
*client.Client
}
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
// TODO(lhf): convert into a Client method.
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
// Retry to ping the Docker daemon until it's ready or the context is canceled.
boff := backoff.WithContext(backoff.NewExponentialBackOff(
+70
View File
@@ -0,0 +1,70 @@
package docker
import (
"context"
"errors"
"fmt"
"time"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/go-connections/nat"
)
// CreateContainerWithImagePull creates a container. If the image is missing, it pulls the image first.
func (cli *Client) CreateContainerWithImagePull(
ctx context.Context, name string, config *container.Config, hostConfig *container.HostConfig,
) (container.CreateResponse, error) {
resp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name)
if err == nil || !errdefs.IsNotFound(err) {
return resp, err
}
pullCh, err := cli.PullImage(ctx, config.Image, image.PullOptions{})
if err != nil {
return resp, fmt.Errorf("pull image: %w", err)
}
// Drain the pull channel until it's closed (image is fully pulled) or an error occurs.
// If the context is canceled during the pull, the channel will receive a context cancellation error.
for msg := range pullCh {
if msg.Err != nil {
return resp, fmt.Errorf("pull image: %w", msg.Err)
}
}
// Create container again after image pull.
if resp, err = cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name); err != nil {
return resp, err
}
return resp, nil
}
// WaitPortPublished waits for a container port to be published on the host which happens asynchronously.
func (cli *Client) WaitPortPublished(ctx context.Context, containerID string, port nat.Port) ([]nat.PortBinding, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
for {
c, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
return nil, fmt.Errorf("inspect container: %w", err)
}
binding, ok := c.NetworkSettings.Ports[port]
if ok && len(binding) > 0 {
return binding, nil
}
select {
case <-time.After(10 * time.Millisecond):
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, errors.New("timeout")
}
return nil, ctx.Err()
}
}
}
+132
View File
@@ -0,0 +1,132 @@
package docker
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
dockercommand "github.com/docker/cli/cli/command"
dockerconfig "github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/pkg/jsonmessage"
)
type PullPushImageMessage struct {
Message jsonmessage.JSONMessage
Err error
}
// PullImage pulls an image and returns a channel to receive progress messages.
func (cli *Client) PullImage(
ctx context.Context, image string, opts image.PullOptions,
) (<-chan PullPushImageMessage, error) {
if opts.RegistryAuth == "" {
// Try to retrieve the authentication token for the image from the default local Docker config file.
if encodedAuth, err := RetrieveLocalDockerRegistryAuth(image); err == nil {
opts.RegistryAuth = encodedAuth
}
}
respBody, err := cli.ImagePull(ctx, image, opts)
if err != nil {
return nil, err
}
ch := make(chan PullPushImageMessage)
go processPullPushImageResp(ctx, respBody, ch)
return ch, nil
}
// PushImage pushes an image and returns a channel to receive progress messages.
func (cli *Client) PushImage(
ctx context.Context, image string, opts image.PushOptions,
) (<-chan PullPushImageMessage, error) {
if opts.RegistryAuth == "" {
// Try to retrieve the authentication token for the image from the default local Docker config file.
encodedAuth, _ := RetrieveLocalDockerRegistryAuth(image)
if encodedAuth == "" {
// If no credentials are found, provide an encoded empty auth config to work around the bug in Docker:
// https://github.com/moby/moby/issues/50729
var err error
if encodedAuth, err = registry.EncodeAuthConfig(registry.AuthConfig{}); err != nil {
return nil, fmt.Errorf("encode empty auth config: %w", err)
}
}
opts.RegistryAuth = encodedAuth
}
respBody, err := cli.ImagePush(ctx, image, opts)
if err != nil {
return nil, err
}
ch := make(chan PullPushImageMessage)
go processPullPushImageResp(ctx, respBody, ch)
return ch, nil
}
// processPullPushImageResp decodes JSON messages from the image pull/push response body and
// sends them to the provided channel.
func processPullPushImageResp(ctx context.Context, respBody io.ReadCloser, ch chan<- PullPushImageMessage) {
defer respBody.Close()
defer close(ch)
decoder := json.NewDecoder(respBody)
for {
var jm jsonmessage.JSONMessage
if err := decoder.Decode(&jm); err != nil {
if errors.Is(err, io.EOF) {
return
}
ch <- PullPushImageMessage{Err: fmt.Errorf("decode image pull/push message: %w", err)}
return
}
msg := PullPushImageMessage{Message: jm}
if jm.Error != nil {
msg.Err = errors.New(jm.Error.Message)
}
select {
case <-ctx.Done():
ch <- PullPushImageMessage{Err: ctx.Err()}
return
default:
ch <- msg
}
}
}
// RetrieveLocalDockerRegistryAuth retrieves the authentication token for the specified image from the local Docker
// config file. It returns the encoded authentication token if it contains any credentials, or an empty string if
// no credentials are found.
func RetrieveLocalDockerRegistryAuth(image string) (string, error) {
// Try to retrieve the authentication token for the image from the default local Docker config file.
dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr)
encodedAuth, err := dockercommand.RetrieveAuthTokenFromImage(dockerConfig, image)
if err != nil {
return "", err
}
// The encodedAuth can be a base64-encoded "{}" (empty JSON object) or include a server address but no credentials.
// Return encodedAuth only if it contains any credentials.
auth, err := registry.DecodeAuthConfig(encodedAuth)
if err != nil {
return "", fmt.Errorf("decode auth config: %w", err)
}
if auth.Username == "" &&
auth.Password == "" &&
auth.Auth == "" &&
auth.IdentityToken == "" &&
auth.RegistryToken == "" {
return "", nil
}
return encodedAuth, nil
}
+96
View File
@@ -0,0 +1,96 @@
package gitutil
import (
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
// GitState contains information about the Git repository state.
type GitState struct {
// Date is the current commit datetime.
Date time.Time
// IsDirty indicates whether there are uncommitted changes.
IsDirty bool
// IsRepo indicates whether the directory is a Git repository.
IsRepo bool
// SHA is the full SHA-1 (40 characters) of the current commit.
SHA string
}
// InspectGitState inspects the Git repo state from the specified directory.
// If the directory is not a Git repository, or if git utility is not available,
// returns GitState with IsRepo=false and no error.
func InspectGitState(dir string) (GitState, error) {
// Check if git command is available.
if _, err := exec.LookPath("git"); err != nil {
return GitState{}, nil
}
state := GitState{
IsRepo: isGitRepo(dir),
}
if !state.IsRepo {
return state, nil
}
// Get the current commit SHA.
sha, err := gitCommand(dir, "rev-parse", "HEAD")
if err != nil {
return state, fmt.Errorf("get current commit SHA: %w", err)
}
state.SHA = strings.TrimSpace(sha)
// Get the current commit timestamp.
timestamp, err := gitCommand(dir, "log", "-1", "--format=%ct")
if err != nil {
return state, fmt.Errorf("get current commit timestamp: %w", err)
}
seconds, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64)
if err != nil {
return state, fmt.Errorf("parse current commit timestamp: %w", err)
}
state.Date = time.Unix(seconds, 0).UTC()
// Check for uncommitted changes.
status, err := gitCommand(dir, "status", "--porcelain")
if err != nil {
return state, fmt.Errorf("check git status: %w", err)
}
state.IsDirty = strings.TrimSpace(status) != ""
return state, nil
}
// isGitRepo checks if the directory is a Git repository.
func isGitRepo(dir string) bool {
_, err := gitCommand(dir, "rev-parse", "--git-dir")
return err == nil
}
// gitCommand runs a git command in the specified directory.
func gitCommand(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("git command failed: %s", exitErr.Stderr)
}
return "", err
}
return string(output), nil
}
// ShortSHA returns a truncated SHA with the specified length.
// If length is negative, zero, or greater than the SHA length, it returns the full SHA.
// Returns empty string if SHA is empty.
func (s *GitState) ShortSHA(length int) string {
if length <= 0 || length > len(s.SHA) {
length = len(s.SHA)
}
return s.SHA[:length]
}
+168
View File
@@ -0,0 +1,168 @@
package gitutil
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInspectGitState_GitNotAvailable(t *testing.T) {
// Reset the PATH env var to simulate git not being available.
t.Setenv("PATH", "")
tmpDir := t.TempDir()
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
assert.False(t, state.IsRepo)
assert.Empty(t, state.SHA)
assert.Empty(t, state.ShortSHA(7))
assert.True(t, state.Date.IsZero())
assert.False(t, state.IsDirty)
}
func TestInspectGitState_NotARepo(t *testing.T) {
// Create a temporary directory that's not a git repo.
tmpDir := t.TempDir()
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
// If tmpDir happens to be in a git repo (e.g., parent directory), skip this test.
// This is common when running tests from within the project itself.
if state.IsRepo {
t.Skip("tmpDir is within a git repository, skipping test")
}
assert.False(t, state.IsRepo)
assert.Empty(t, state.SHA)
assert.Empty(t, state.ShortSHA(7))
assert.True(t, state.Date.IsZero()) // Should have zero time if not a repo.
assert.False(t, state.IsDirty)
}
func TestInspectGitState_CleanRepo(t *testing.T) {
// Create a temporary git repo.
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
createAndCommitFile(t, tmpDir, "test.txt", "test content")
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
assert.True(t, state.IsRepo)
assert.Len(t, state.SHA, 40)
assert.Equal(t, state.ShortSHA(7), state.SHA[:7])
assert.False(t, state.Date.IsZero())
assert.False(t, state.IsDirty)
}
func TestInspectGitState_DirtyRepo(t *testing.T) {
// Create a temporary git repo.
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
createAndCommitFile(t, tmpDir, "test.txt", "test content")
// Modify the file to make the repo dirty.
err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("modified content"), 0o644)
require.NoError(t, err)
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
assert.True(t, state.IsRepo)
assert.True(t, state.IsDirty)
}
func TestInspectGitState_UntrackedFiles(t *testing.T) {
// Create a temporary git repo.
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
createAndCommitFile(t, tmpDir, "test.txt", "test content")
// Add an untracked file.
err := os.WriteFile(filepath.Join(tmpDir, "untracked.txt"), []byte("untracked"), 0o644)
require.NoError(t, err)
state, err := InspectGitState(tmpDir)
require.NoError(t, err)
assert.True(t, state.IsRepo)
assert.True(t, state.IsDirty) // Untracked files make the repo dirty.
}
func TestGitState_ShortSHA(t *testing.T) {
state := &GitState{
SHA: "1234567890abcdef1234567890abcdef12345678",
}
tests := []struct {
name string
length int
expected string
}{
{"short 7", 7, "1234567"},
{"short 10", 10, "1234567890"},
{"full SHA", 40, "1234567890abcdef1234567890abcdef12345678"},
{"longer than SHA", 50, "1234567890abcdef1234567890abcdef12345678"},
{"negative", -42, "1234567890abcdef1234567890abcdef12345678"},
{"zero", 0, "1234567890abcdef1234567890abcdef12345678"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := state.ShortSHA(tt.length)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGitState_ShortSHA_Empty(t *testing.T) {
state := &GitState{
SHA: "",
}
result := state.ShortSHA(7)
assert.Empty(t, result)
}
// Helper functions.
func initGitRepo(t *testing.T, dir string) {
t.Helper()
// Initialize git repo.
runGitCommand(t, dir, "init")
runGitCommand(t, dir, "config", "user.email", "test@example.com")
runGitCommand(t, dir, "config", "user.name", "Test User")
}
func createAndCommitFile(t *testing.T, dir, filename, content string) {
t.Helper()
// Create file.
path := filepath.Join(dir, filename)
err := os.WriteFile(path, []byte(content), 0o644)
require.NoError(t, err)
// Commit file.
runGitCommand(t, dir, "add", filename)
runGitCommand(t, dir, "commit", "-m", "Add "+filename)
}
func runGitCommand(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
require.NoError(t, err, "git command failed: %s", output)
}
+19
View File
@@ -0,0 +1,19 @@
package log
import (
"log/slog"
"os"
"slices"
"strings"
)
func InitLoggerFromEnv() {
debugValues := []string{"1", "true", "yes"}
if slices.Contains(debugValues, strings.ToLower(os.Getenv("DEBUG"))) {
logger := slog.New(NewSlogTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
slog.SetDefault(logger)
}
slog.Debug("logger initialized")
}
File diff suppressed because it is too large Load Diff
+82 -1
View File
@@ -5,6 +5,7 @@ package api;
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
import "internal/machine/api/pb/common.proto";
service Docker {
@@ -15,11 +16,15 @@ service Docker {
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
rpc ExecContainer(stream ExecContainerRequest) returns (stream ExecContainerResponse);
rpc ContainerLogs(ContainerLogsRequest) returns (stream ContainerLogEntry);
rpc PullImage(PullImageRequest) returns (stream JSONMessage);
rpc InspectImage(InspectImageRequest) returns (InspectImageResponse);
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
// Docker auth credentials if necessary.
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
@@ -91,6 +96,64 @@ message RemoveContainerRequest {
bytes options = 2;
}
message ExecContainerRequest {
oneof payload {
// Initial configuration for the exec session. Must be sent as the first message.
ExecConfig config = 1;
// Raw stdin data to be written to the exec process.
bytes stdin = 2;
// TTY resize event (only used when TTY is enabled).
ResizeEvent resize = 3;
}
}
message ExecConfig {
// Container ID to execute the command in.
string container_id = 1;
// JSON serialised ExecOptions
bytes options = 2;
}
message ResizeEvent {
uint32 height = 1;
uint32 width = 2;
}
message ExecContainerResponse {
oneof payload {
// Exec instance ID returned after creating the exec.
string exec_id = 1;
// Raw stdout data from the exec process.
bytes stdout = 2;
// Raw stderr data from the exec process (only when TTY is disabled).
bytes stderr = 3;
// Exit code of the exec process. Sent as the final message.
int32 exit_code = 4;
}
}
message ContainerLogsRequest {
string container_id = 1;
// Options for logs retrieval.
bool follow = 2;
int32 tail = 3; // -1 means all
string since = 4; // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
string until = 5; // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
}
message ContainerLogEntry {
enum StreamType {
UNKNOWN = 0;
STDOUT = 1;
STDERR = 2;
HEARTBEAT = 3;
}
StreamType stream = 1;
google.protobuf.Timestamp timestamp = 2;
// Log line content. Empty for heartbeat entries.
bytes message = 3;
}
message PullImageRequest {
string image = 1;
// JSON serialised image.PullOptions.
@@ -113,7 +176,7 @@ message InspectImageResponse {
message Image {
Metadata metadata = 1;
// JSON serialised types.ImageInspect.
// JSON serialised image.InspectResponse.
bytes image = 2;
}
@@ -134,6 +197,24 @@ message RemoteImage {
bytes manifest = 3;
}
message ListImagesRequest {
// JSON serialised image.ListOptions.
bytes options = 1;
}
message ListImagesResponse {
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
repeated MachineImages messages = 1;
}
message MachineImages {
Metadata metadata = 1;
// JSON serialised []image.Summary.
bytes images = 2;
// True if Docker uses the containerd image store, false if it uses its internal image store.
bool containerd_store = 3;
}
message CreateVolumeRequest {
// JSON serialised volume.CreateOptions.
bytes options = 1;
+112 -1
View File
@@ -26,9 +26,12 @@ const (
Docker_StopContainer_FullMethodName = "/api.Docker/StopContainer"
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
Docker_ExecContainer_FullMethodName = "/api.Docker/ExecContainer"
Docker_ContainerLogs_FullMethodName = "/api.Docker/ContainerLogs"
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
@@ -48,11 +51,14 @@ type DockerClient interface {
StopContainer(ctx context.Context, in *StopContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error)
RemoveContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error)
ContainerLogs(ctx context.Context, in *ContainerLogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ContainerLogEntry], error)
PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error)
InspectImage(ctx context.Context, in *InspectImageRequest, opts ...grpc.CallOption) (*InspectImageResponse, error)
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
// Docker auth credentials if necessary.
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error)
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
@@ -130,9 +136,41 @@ func (c *dockerClient) RemoveContainer(ctx context.Context, in *RemoveContainerR
return out, nil
}
func (c *dockerClient) ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[0], Docker_ExecContainer_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[ExecContainerRequest, ExecContainerResponse]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Docker_ExecContainerClient = grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse]
func (c *dockerClient) ContainerLogs(ctx context.Context, in *ContainerLogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ContainerLogEntry], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[1], Docker_ContainerLogs_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[ContainerLogsRequest, ContainerLogEntry]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Docker_ContainerLogsClient = grpc.ServerStreamingClient[ContainerLogEntry]
func (c *dockerClient) PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[0], Docker_PullImage_FullMethodName, cOpts...)
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[2], Docker_PullImage_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
@@ -169,6 +207,16 @@ func (c *dockerClient) InspectRemoteImage(ctx context.Context, in *InspectRemote
return out, nil
}
func (c *dockerClient) ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListImagesResponse)
err := c.cc.Invoke(ctx, Docker_ListImages_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateVolumeResponse)
@@ -249,11 +297,14 @@ type DockerServer interface {
StopContainer(context.Context, *StopContainerRequest) (*emptypb.Empty, error)
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error
ContainerLogs(*ContainerLogsRequest, grpc.ServerStreamingServer[ContainerLogEntry]) error
PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error
InspectImage(context.Context, *InspectImageRequest) (*InspectImageResponse, error)
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
// Docker auth credentials if necessary.
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
@@ -289,6 +340,12 @@ func (UnimplementedDockerServer) ListContainers(context.Context, *ListContainers
func (UnimplementedDockerServer) RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveContainer not implemented")
}
func (UnimplementedDockerServer) ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error {
return status.Errorf(codes.Unimplemented, "method ExecContainer not implemented")
}
func (UnimplementedDockerServer) ContainerLogs(*ContainerLogsRequest, grpc.ServerStreamingServer[ContainerLogEntry]) error {
return status.Errorf(codes.Unimplemented, "method ContainerLogs not implemented")
}
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
return status.Errorf(codes.Unimplemented, "method PullImage not implemented")
}
@@ -298,6 +355,9 @@ func (UnimplementedDockerServer) InspectImage(context.Context, *InspectImageRequ
func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method InspectRemoteImage not implemented")
}
func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented")
}
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
}
@@ -448,6 +508,24 @@ func _Docker_RemoveContainer_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _Docker_ExecContainer_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(DockerServer).ExecContainer(&grpc.GenericServerStream[ExecContainerRequest, ExecContainerResponse]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Docker_ExecContainerServer = grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]
func _Docker_ContainerLogs_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(ContainerLogsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(DockerServer).ContainerLogs(m, &grpc.GenericServerStream[ContainerLogsRequest, ContainerLogEntry]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Docker_ContainerLogsServer = grpc.ServerStreamingServer[ContainerLogEntry]
func _Docker_PullImage_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(PullImageRequest)
if err := stream.RecvMsg(m); err != nil {
@@ -495,6 +573,24 @@ func _Docker_InspectRemoteImage_Handler(srv interface{}, ctx context.Context, de
return interceptor(ctx, in, info, handler)
}
func _Docker_ListImages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListImagesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DockerServer).ListImages(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Docker_ListImages_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DockerServer).ListImages(ctx, req.(*ListImagesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateVolumeRequest)
if err := dec(in); err != nil {
@@ -660,6 +756,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
MethodName: "InspectRemoteImage",
Handler: _Docker_InspectRemoteImage_Handler,
},
{
MethodName: "ListImages",
Handler: _Docker_ListImages_Handler,
},
{
MethodName: "CreateVolume",
Handler: _Docker_CreateVolume_Handler,
@@ -690,6 +790,17 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "ExecContainer",
Handler: _Docker_ExecContainer_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "ContainerLogs",
Handler: _Docker_ContainerLogs_Handler,
ServerStreams: true,
},
{
StreamName: "PullImage",
Handler: _Docker_PullImage_Handler,
@@ -7,7 +7,6 @@ import (
"testing"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/psviderski/uncloud/internal/machine/docker"
@@ -848,15 +847,15 @@ func newContainerRecordWithCaddyConfig(serviceName, ip, caddyConfig, machineID s
return store.ContainerRecord{
Container: api.ServiceContainer{
Container: api.Container{
ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
ID: serviceName + "-" + ip, // Add ID for stable sorting
State: &types.ContainerState{
State: &container.State{
Running: true,
},
Created: created.UTC().Format(time.RFC3339Nano),
},
NetworkSettings: &types.NetworkSettings{
NetworkSettings: &container.NetworkSettings{
Networks: map[string]*network.EndpointSettings{
docker.NetworkName: {
IPAddress: ip,
@@ -975,15 +974,15 @@ func newContainerRecordWithPorts(serviceName, ip string, ports []string, machine
return store.ContainerRecord{
Container: api.ServiceContainer{
Container: api.Container{
ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
ID: serviceName + "-" + ip, // Add ID for stable sorting
State: &types.ContainerState{
State: &container.State{
Running: true,
},
Created: time.Now().UTC().Format(time.RFC3339Nano),
},
NetworkSettings: &types.NetworkSettings{
NetworkSettings: &container.NetworkSettings{
Networks: map[string]*network.EndpointSettings{
docker.NetworkName: {
IPAddress: ip,
@@ -4,7 +4,6 @@ import (
"strings"
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/psviderski/uncloud/internal/machine/docker"
@@ -334,13 +333,13 @@ func TestGenerateJSONConfig(t *testing.T) {
func newContainer(ip string, ports ...string) api.ServiceContainer {
portsLabel := strings.Join(ports, ",")
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
State: &types.ContainerState{
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
State: &container.State{
Running: true,
},
},
NetworkSettings: &types.NetworkSettings{
NetworkSettings: &container.NetworkSettings{
Networks: map[string]*network.EndpointSettings{
docker.NetworkName: {
IPAddress: ip,
@@ -357,13 +356,13 @@ func newContainer(ip string, ports ...string) api.ServiceContainer {
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
portsLabel := strings.Join(ports, ",")
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
State: &types.ContainerState{
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
State: &container.State{
Running: true,
},
},
NetworkSettings: &types.NetworkSettings{
NetworkSettings: &container.NetworkSettings{
Networks: map[string]*network.EndpointSettings{
"other-network": {
IPAddress: "172.17.0.2",
+2 -2
View File
@@ -27,9 +27,9 @@ func (s *Server) GetConfig(ctx context.Context, _ *emptypb.Empty) (*pb.GetCaddyC
caddyfile, modifiedAt, err := s.service.Caddyfile()
if err != nil {
if os.IsNotExist(err) {
return nil, status.Errorf(codes.NotFound, err.Error())
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.GetCaddyConfigResponse{
+38 -2
View File
@@ -21,6 +21,7 @@ import (
"github.com/psviderski/uncloud/internal/machine/firewall"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/unregistry"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
)
@@ -45,6 +46,8 @@ type clusterController struct {
// dnsServer is the embedded internal DNS server for the cluster listening on the machine IP.
dnsServer *dns.Server
dnsResolver *dns.ClusterResolver
// unregistry is the embedded container registry that uses the local Docker (containerd) image store as its backend.
unregistry *unregistry.Registry
// stopped is a channel that is closed when the controller is stopped.
stopped chan struct{}
@@ -60,6 +63,7 @@ func newClusterController(
caddyfileCtrl *caddyconfig.Controller,
dnsServer *dns.Server,
dnsResolver *dns.ClusterResolver,
unregistry *unregistry.Registry,
) (*clusterController, error) {
slog.Info("Starting WireGuard network.")
wgnet, err := network.NewWireGuardNetwork()
@@ -80,6 +84,7 @@ func newClusterController(
caddyconfigCtrl: caddyfileCtrl,
dnsServer: dnsServer,
dnsResolver: dnsResolver,
unregistry: unregistry,
stopped: make(chan struct{}),
}, nil
}
@@ -87,7 +92,7 @@ func newClusterController(
func (cc *clusterController) Run(ctx context.Context) error {
defer close(cc.stopped)
if err := firewall.ConfigureIptablesChains(); err != nil {
if err := firewall.ConfigureIptablesChains(network.MachineIP(cc.state.Network.Subnet)); err != nil {
return fmt.Errorf("configure iptables chains: %w", err)
}
@@ -164,7 +169,7 @@ func (cc *clusterController) Run(ctx context.Context) error {
return nil
})
// Watch for endpoint changes and update the machine state accordingly.
// Watch for WireGuard peer endpoint changes and update the machine state accordingly.
errGroup.Go(func() error {
for {
select {
@@ -210,6 +215,16 @@ func (cc *clusterController) Run(ctx context.Context) error {
return nil
})
if cc.unregistry != nil {
errGroup.Go(func() error {
slog.Info("Starting unregistry server.")
if err := cc.unregistry.ListenAndServe(); err != nil {
return fmt.Errorf("unregistry server failed: %w", err)
}
return nil
})
}
// Wait for the context to be done and stop the network API server.
<-ctx.Done()
slog.Info("Stopping network API server.")
@@ -217,6 +232,19 @@ func (cc *clusterController) Run(ctx context.Context) error {
cc.server.GracefulStop()
slog.Info("Network API server stopped.")
// Stop the unregistry server with a timeout if it was started.
if cc.unregistry != nil {
unregTimeout := 30 * time.Second
slog.Info("Stopping unregistry server.", "timeout", unregTimeout)
unregCtx, cancel := context.WithTimeout(context.Background(), unregTimeout)
defer cancel()
if err = cc.unregistry.Shutdown(unregCtx); err != nil {
return fmt.Errorf("unregistry server forced to shutdown: %w", err)
}
slog.Info("Unregistry server stopped.")
}
// Wait for all controllers to finish.
err = errGroup.Wait()
@@ -330,6 +358,14 @@ func (cc *clusterController) handleMachineChanges(ctx context.Context) error {
slog.Error("Failed to list machines.", "err", err)
continue
}
// Skip reconfiguration if the machines list is empty. This can happen when joining the cluster.
// Corrosion can notifies about table changes before the data is fully replicated.
// Reconfiguring with an empty list would remove all peers and lock this machine out of the cluster.
// See https://github.com/psviderski/uncloud/issues/155.
if len(machines) == 0 {
slog.Debug("Skipping peer reconfiguration: machines list in store is empty.")
continue
}
if err = cc.configurePeers(machines); err != nil {
slog.Error("Failed to configure peers.", "err", err)
}
+1 -1
View File
@@ -44,7 +44,7 @@ func (c *Cluster) ReserveDomain(ctx context.Context, req *pb.ReserveDomainReques
dnsClient := dns.NewClient()
name, token, err := dnsClient.ReserveDomain(req.Endpoint)
if err != nil {
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
domain := uncloudDNSDomain{
+2
View File
@@ -3,4 +3,6 @@ package constants
const (
// MachineAPIPort is the port for the Machine API service on the management WireGuard network.
MachineAPIPort = 51000
// UnregistryPort is the port for the embedded container registry listening on the machine IP.
UnregistryPort = 5000
)
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"time"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
@@ -30,7 +31,7 @@ type DockerService struct {
func (s *DockerService) Start(ctx context.Context) error {
_, err := s.Client.ContainerInspect(ctx, s.Name)
if err != nil {
if !client.IsErrNotFound(err) {
if !errdefs.IsNotFound(err) {
return fmt.Errorf("inspect container %q: %w", s.Name, err)
}
if err = s.startNewContainer(ctx); err != nil {
@@ -114,7 +115,7 @@ func (s *DockerService) hostConfig() *container.HostConfig {
func (s *DockerService) startNewContainer(ctx context.Context) error {
_, err := s.Client.ContainerCreate(ctx, s.containerConfig(), s.hostConfig(), nil, nil, s.Name)
if err != nil {
if !client.IsErrNotFound(err) {
if !errdefs.IsNotFound(err) {
return fmt.Errorf("create container: %w", err)
}
+6 -1
View File
@@ -91,6 +91,11 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
newServiceIPs[ctr.ServiceName()] = append(newServiceIPs[ctr.ServiceName()], ip)
// Also add the service ID as a valid lookup.
newServiceIPs[ctr.ServiceID()] = append(newServiceIPs[ctr.ServiceID()], ip)
// Add <machine-id>.m.<service-name> as a lookup
serviceNameWithMachineID := record.MachineID + ".m." + ctr.ServiceName()
newServiceIPs[serviceNameWithMachineID] = append(newServiceIPs[serviceNameWithMachineID], ip)
containersCount++
}
@@ -99,7 +104,7 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
r.serviceIPs = newServiceIPs
r.mu.Unlock()
r.log.Debug("DNS records updated.", "services", len(newServiceIPs)/2, "containers", containersCount)
r.log.Debug("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
}
// Resolve returns IP addresses of the service containers.
+34 -3
View File
@@ -8,6 +8,7 @@ import (
"math/rand/v2"
"net"
"net/netip"
"slices"
"strconv"
"strings"
"sync"
@@ -40,6 +41,7 @@ type Resolver interface {
// to upstream DNS servers.
type Server struct {
listenAddr netip.Addr
localSubnet netip.Prefix
resolver Resolver
upstreamServers []netip.AddrPort
@@ -53,7 +55,7 @@ type Server struct {
// NewServer creates a new DNS server with the given configuration.
// If upstreams is nil, nameservers from /etc/resolv.conf will be used. An empty upstreams list means to only resolve
// internal DNS queries and not forward any external queries.
func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) {
func NewServer(listenAddr netip.Addr, localSubnet netip.Prefix, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) {
if !listenAddr.IsValid() {
return nil, fmt.Errorf("invalid listen address: %s", listenAddr)
}
@@ -87,6 +89,7 @@ func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrP
return &Server{
listenAddr: listenAddr,
localSubnet: localSubnet,
resolver: resolver,
upstreamServers: upstreams,
forwardSemaphore: make(chan struct{}, maxConcurrentForwards),
@@ -287,7 +290,7 @@ func (s *Server) forwardRequest(req *dns.Msg, proto string) (*dns.Msg, error) {
// handleAQuery processes an A query for the internal domain and returns A records for the requested name.
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
func (s *Server) handleAQuery(name string) []dns.RR {
serviceName := trimInternalDomain(name)
serviceName, mode := extractModeFromDomain(trimInternalDomain(name))
ips := s.resolver.Resolve(serviceName)
if len(ips) == 0 {
s.log.Debug("Failed to resolve service name.", "service", serviceName)
@@ -296,10 +299,28 @@ func (s *Server) handleAQuery(name string) []dns.RR {
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips)
if len(ips) > 1 {
// TODO: sort by proximity to the requesting container/machine. For now, just shuffle the IPs.
// Shuffle the IPs to approximate round-robin.
// We want to do this as a baseline for "nearest" mode, as well.
rand.Shuffle(len(ips), func(i, j int) {
ips[i], ips[j] = ips[j], ips[i]
})
// Default (mode == "") currently behaves the same as round-robin,
// and nothing additional to do for round-robin (mode == "rr").
if mode == "nearest" {
// Sort IPs on local subnet to the top.
slices.SortFunc(ips, func(a, b netip.Addr) int {
aIsLocal := s.localSubnet.Contains(a)
bIsLocal := s.localSubnet.Contains(b)
if aIsLocal && !bIsLocal {
return -1
} else if bIsLocal && !aIsLocal {
return 1
}
return 0
})
}
}
// Create A records for each IP.
@@ -345,3 +366,13 @@ func trimInternalDomain(name string) string {
return strings.TrimSuffix(name, "."+InternalDomain)
}
func extractModeFromDomain(name string) (string, string) {
modes := []string{"nearest", "rr"}
for _, mode := range modes {
if cut, found := strings.CutPrefix(name, mode+"."); found {
return cut, mode
}
}
return name, ""
}
+29 -34
View File
@@ -8,7 +8,6 @@ import (
"io"
"github.com/distribution/reference"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/volume"
@@ -16,6 +15,7 @@ import (
"github.com/docker/docker/pkg/jsonmessage"
regtypes "github.com/google/go-containerregistry/pkg/v1/types"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/psviderski/uncloud/internal/docker"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/grpc"
@@ -24,16 +24,18 @@ import (
)
// Client is a gRPC client for the Docker service that provides a similar interface to the Docker HTTP client.
// TODO: it doesn't seem there is much value in having this intermediate Docker client.
// Consider merging it into the main pkg/client.
type Client struct {
conn *grpc.ClientConn
grpcClient pb.DockerClient
GRPCClient pb.DockerClient
}
// NewClient creates a new Docker gRPC client with the provided gRPC connection.
func NewClient(conn *grpc.ClientConn) *Client {
return &Client{
conn: conn,
grpcClient: pb.NewDockerClient(conn),
GRPCClient: pb.NewDockerClient(conn),
}
}
@@ -70,7 +72,7 @@ func (c *Client) CreateContainer(
return resp, fmt.Errorf("marshal platform: %w", err)
}
grpcResp, err := c.grpcClient.CreateContainer(ctx, &pb.CreateContainerRequest{
grpcResp, err := c.GRPCClient.CreateContainer(ctx, &pb.CreateContainerRequest{
Config: configBytes,
HostConfig: hostConfigBytes,
NetworkConfig: networkingConfigBytes,
@@ -91,10 +93,10 @@ func (c *Client) CreateContainer(
}
// InspectContainer returns the container information for the given container ID.
func (c *Client) InspectContainer(ctx context.Context, id string) (types.ContainerJSON, error) {
var resp types.ContainerJSON
func (c *Client) InspectContainer(ctx context.Context, id string) (container.InspectResponse, error) {
var resp container.InspectResponse
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
grpcResp, err := c.GRPCClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
if err != nil {
if status.Convert(err).Code() == codes.NotFound {
return resp, errdefs.NotFound(err)
@@ -115,7 +117,7 @@ func (c *Client) StartContainer(ctx context.Context, id string, opts container.S
return fmt.Errorf("marshal options: %w", err)
}
_, err = c.grpcClient.StartContainer(ctx, &pb.StartContainerRequest{
_, err = c.GRPCClient.StartContainer(ctx, &pb.StartContainerRequest{
Id: id,
Options: optsBytes,
})
@@ -134,7 +136,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
return fmt.Errorf("marshal options: %w", err)
}
_, err = c.grpcClient.StopContainer(ctx, &pb.StopContainerRequest{
_, err = c.GRPCClient.StopContainer(ctx, &pb.StopContainerRequest{
Id: id,
Options: optsBytes,
})
@@ -148,7 +150,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
type MachineContainers struct {
Metadata *pb.Metadata
Containers []types.ContainerJSON
Containers []container.InspectResponse
}
func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions) ([]MachineContainers, error) {
@@ -157,7 +159,7 @@ func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions)
return nil, fmt.Errorf("marshal options: %w", err)
}
resp, err := c.grpcClient.ListContainers(ctx, &pb.ListContainersRequest{Options: optsBytes})
resp, err := c.GRPCClient.ListContainers(ctx, &pb.ListContainersRequest{Options: optsBytes})
if err != nil {
return nil, err
}
@@ -184,7 +186,7 @@ func (c *Client) RemoveContainer(ctx context.Context, id string, opts container.
return fmt.Errorf("marshal options: %w", err)
}
_, err = c.grpcClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
_, err = c.GRPCClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
Id: id,
Options: optsBytes,
})
@@ -205,23 +207,18 @@ type PullOptions struct {
Platform string
}
type PullImageMessage struct {
Message jsonmessage.JSONMessage
Err error
}
func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions) (<-chan PullImageMessage, error) {
func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions) (<-chan docker.PullPushImageMessage, error) {
optsBytes, err := json.Marshal(opts)
if err != nil {
return nil, fmt.Errorf("marshal options: %w", err)
}
stream, err := c.grpcClient.PullImage(ctx, &pb.PullImageRequest{Image: image, Options: optsBytes})
stream, err := c.GRPCClient.PullImage(ctx, &pb.PullImageRequest{Image: image, Options: optsBytes})
if err != nil {
return nil, err
}
ch := make(chan PullImageMessage)
ch := make(chan docker.PullPushImageMessage)
go func() {
defer close(ch)
@@ -232,16 +229,16 @@ func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions)
return
}
if err != nil {
ch <- PullImageMessage{Err: err}
ch <- docker.PullPushImageMessage{Err: err}
return
}
var jm jsonmessage.JSONMessage
if err = json.Unmarshal(msg.Message, &jm); err != nil {
ch <- PullImageMessage{Err: fmt.Errorf("unmarshal JSON message: %w", err)}
ch <- docker.PullPushImageMessage{Err: fmt.Errorf("unmarshal JSON message: %w", err)}
return
}
ch <- PullImageMessage{Message: jm}
ch <- docker.PullPushImageMessage{Message: jm}
}
}()
@@ -250,7 +247,7 @@ func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions)
// InspectImage returns the image information for the given image ID. The request may be sent to multiple machines.
func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
resp, err := c.grpcClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
resp, err := c.GRPCClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
if err != nil {
// If the request was sent to only one machine, err is an actual error from the machine.
if status.Convert(err).Code() == codes.NotFound {
@@ -288,7 +285,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
// credentials if necessary. If the response from a machine doesn't contain an error, the api.RemoteImage will either
// contain an IndexManifest or an ImageManifest.
func (c *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
resp, err := c.grpcClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
resp, err := c.GRPCClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
if err != nil {
return nil, err
}
@@ -365,7 +362,7 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
return vol, fmt.Errorf("marshal options: %w", err)
}
resp, err := c.grpcClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
resp, err := c.GRPCClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
if err != nil {
return vol, err
}
@@ -377,20 +374,18 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
return vol, nil
}
// MachineVolumes represents a volume list response from a machine.
type MachineVolumes struct {
Metadata *pb.Metadata
Response volume.ListResponse
}
// ListVolumes returns a list of all volumes matching the filter.
func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]MachineVolumes, error) {
optsBytes, err := json.Marshal(opts)
if err != nil {
return nil, fmt.Errorf("marshal options: %w", err)
}
resp, err := c.grpcClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
resp, err := c.GRPCClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
if err != nil {
return nil, err
}
@@ -412,7 +407,7 @@ func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]Ma
// RemoveVolume removes a volume with the given ID.
func (c *Client) RemoveVolume(ctx context.Context, id string, force bool) error {
_, err := c.grpcClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
_, err := c.GRPCClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
Id: id,
Force: force,
})
@@ -435,7 +430,7 @@ func (c *Client) CreateServiceContainer(
if err != nil {
return resp, fmt.Errorf("marshal service spec: %w", err)
}
grpcResp, err := c.grpcClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
grpcResp, err := c.GRPCClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
ServiceId: serviceID,
ServiceSpec: specBytes,
ContainerName: containerName,
@@ -458,7 +453,7 @@ func (c *Client) CreateServiceContainer(
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
var resp api.ServiceContainer
grpcResp, err := c.grpcClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
grpcResp, err := c.GRPCClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
if err != nil {
if status.Convert(err).Code() == codes.NotFound {
return resp, errdefs.NotFound(err)
@@ -491,7 +486,7 @@ func (c *Client) ListServiceContainers(
return nil, fmt.Errorf("marshal options: %w", err)
}
resp, err := c.grpcClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
resp, err := c.GRPCClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
ServiceId: serviceNameOrID,
Options: optsBytes,
})
@@ -530,7 +525,7 @@ func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts con
return fmt.Errorf("marshal options: %w", err)
}
_, err = c.grpcClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
_, err = c.GRPCClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
Id: id,
Options: optsBytes,
})
+303
View File
@@ -0,0 +1,303 @@
package docker
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"github.com/moby/term"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"golang.org/x/sync/errgroup"
"golang.org/x/sys/unix"
)
// ExecConfig contains options for executing a command in a container.
type ExecConfig struct {
// Container ID to execute the command in.
ContainerID string
// Exec configuration.
Options api.ExecOptions
}
// sendResizeRequest sends a terminal resize request to the exec stream.
func sendResizeRequest(stream pb.Docker_ExecContainerClient, size *term.Winsize) error {
slog.Debug("sending resize request", "width", size.Width, "height", size.Height)
return stream.Send(
&pb.ExecContainerRequest{
Payload: &pb.ExecContainerRequest_Resize{
Resize: &pb.ResizeEvent{
Height: uint32(size.Height),
Width: uint32(size.Width),
},
},
})
}
// setupTerminal configures the terminal for interactive TTY sessions.
// It checks if stdin is a terminal, sets it to raw mode, and sets up resize handling.
// Returns a cleanup function to restore terminal state, or an error.
func setupTerminal(ctx context.Context, stream pb.Docker_ExecContainerClient) (func(), error) {
inFd, isTerminal := term.GetFdInfo(os.Stdin)
if !isTerminal {
return nil, fmt.Errorf("stdin is not a terminal")
}
// Set terminal to raw mode
oldState, err := term.SetRawTerminal(inFd)
if err != nil {
return nil, fmt.Errorf("set raw terminal: %w", err)
}
// Cleanup function
restoreFunc := func() {
_ = term.RestoreTerminal(inFd, oldState)
}
// Set up resize handling
if err := handleTerminalResize(ctx, inFd, stream); err != nil {
restoreFunc()
return nil, err
}
return restoreFunc, nil
}
// handleTerminalResize sends initial window size and handles window resize signals for TTY sessions.
func handleTerminalResize(ctx context.Context, inFd uintptr, stream pb.Docker_ExecContainerClient) error {
// Handle window resize signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, unix.SIGWINCH)
// Send initial window size
if size, err := term.GetWinsize(inFd); err == nil {
_ = sendResizeRequest(stream, size)
}
go func() {
defer signal.Stop(sigCh)
for {
select {
case <-ctx.Done():
return
case <-sigCh:
size, err := term.GetWinsize(inFd)
if err != nil {
slog.Debug("get window size", "error", err)
continue
}
if err = sendResizeRequest(stream, size); err != nil {
slog.Debug("send resize request", "error", err)
}
}
}
}()
return nil
}
// handleClientInputStream reads from stdin and sends data to the remote server.
// It also periodically checks for context cancellation to exit gracefully when e.g.
// the output stream is closed.
func handleClientInputStream(ctx context.Context, stream pb.Docker_ExecContainerClient, stdin io.Reader) error {
slog.Debug("Input goroutine started")
defer slog.Debug("Input goroutine exited")
defer stream.CloseSend()
// Channel to receive stdin data
stdinCh := make(chan []byte)
stdinErrCh := make(chan error, 1)
// Read from stdin in a separate goroutine
// Note: this goroutine may continue blocking on Read even after we exit from the function,
// but that's OK - it will eventually unblock when data arrives or stdin closes.
go func() {
buf := make([]byte, 32*1024) // 32KB buffer
for {
n, err := stdin.Read(buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
select {
case stdinCh <- data:
case <-ctx.Done():
slog.Debug("stdin reader exiting due to context done")
return
}
}
if err != nil {
if err == io.EOF {
slog.Debug("stdin reader: EOF received")
} else {
slog.Debug("stdin reader error", "error", err)
}
stdinErrCh <- err
return
}
}
}()
// Send stdin data to the server or exit when context is cancelled
for {
select {
case <-ctx.Done():
return nil
case data := <-stdinCh:
if err := stream.Send(&pb.ExecContainerRequest{
Payload: &pb.ExecContainerRequest_Stdin{Stdin: data},
}); err != nil {
return fmt.Errorf("send stdin: %w", err)
}
case err := <-stdinErrCh:
if err != io.EOF {
return fmt.Errorf("read stdin: %w", err)
}
return nil
}
}
}
// handleClientOutputStream receives output from the exec stream and writes to stdout/stderr.
// It also captures the exit code and signals completion via context cancellation.
func handleClientOutputStream(ctx context.Context, stream pb.Docker_ExecContainerClient, stdout, stderr io.Writer, exitCode *int) error {
slog.Debug("Output goroutine started")
defer slog.Debug("Output goroutine exited")
for {
resp, err := stream.Recv()
if err == io.EOF {
slog.Debug("output stream: EOF received")
return nil
}
if err != nil {
return fmt.Errorf("receive from stream: %w", err)
}
switch payload := resp.Payload.(type) {
case *pb.ExecContainerResponse_ExecId:
// This is sent first; we already processed it earlier, so just ignore duplicates.
case *pb.ExecContainerResponse_Stdout:
if _, err := stdout.Write(payload.Stdout); err != nil {
return fmt.Errorf("write stdout: %w", err)
}
case *pb.ExecContainerResponse_Stderr:
if _, err := stderr.Write(payload.Stderr); err != nil {
return fmt.Errorf("write stderr: %w", err)
}
case *pb.ExecContainerResponse_ExitCode:
slog.Debug("received exit code", "code", payload.ExitCode)
*exitCode = int(payload.ExitCode)
return nil
}
}
}
// 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) {
// 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)
// Initialize exit code to non-zero in case we have to return early
exitCode = 1
// Set up I/O streams - use custom streams if provided, otherwise default to os.Stdin/Stdout/Stderr
stdin := io.Reader(os.Stdin)
stdout := io.Writer(os.Stdout)
stderr := io.Writer(os.Stderr)
if opts.Options.Stdin != nil {
stdin = opts.Options.Stdin
}
if opts.Options.Stdout != nil {
stdout = opts.Options.Stdout
}
if opts.Options.Stderr != nil {
stderr = opts.Options.Stderr
}
// Marshal the exec config
configBytes, err := json.Marshal(opts.Options)
if err != nil {
return -1, fmt.Errorf("marshal exec config: %w", err)
}
// Create the bidirectional stream
stream, err := c.GRPCClient.ExecContainer(ctx)
if err != nil {
return -1, fmt.Errorf("create exec stream: %w", err)
}
// Send the initial configuration
if err := stream.Send(&pb.ExecContainerRequest{
Payload: &pb.ExecContainerRequest_Config{
Config: &pb.ExecConfig{
ContainerId: opts.ContainerID,
Options: configBytes,
},
},
}); err != nil {
return -1, fmt.Errorf("send exec config: %w", err)
}
// Receive the exec ID
resp, err := stream.Recv()
if err != nil {
return -1, fmt.Errorf("receive exec ID: %w", err)
}
execID := resp.GetExecId()
if execID == "" {
return -1, fmt.Errorf("expected exec ID in first response")
}
errGroup, ctx := errgroup.WithContext(ctx)
// Create cancellable context for goroutine coordination
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Handle terminal setup for interactive sessions
if opts.Options.AttachStdin && opts.Options.Tty {
restoreTerminal, err := setupTerminal(ctx, stream)
if err != nil {
return -1, fmt.Errorf("setup terminal: %w", err)
}
if restoreTerminal != nil {
defer restoreTerminal()
}
}
// Handle stdin stream if needed
if opts.Options.AttachStdin {
errGroup.Go(func() error {
return handleClientInputStream(ctx, stream, stdin)
})
} else {
// Close send direction immediately if not attaching stdin
stream.CloseSend()
}
// Handle output streams (stdout/stderr)
errGroup.Go(func() error {
defer cancel()
return handleClientOutputStream(ctx, stream, stdout, stderr, &exitCode)
})
err = errGroup.Wait()
if err == nil && opts.Options.Detach {
return 0, nil
}
return exitCode, err
}
+6 -6
View File
@@ -8,10 +8,10 @@ import (
"net/netip"
"strconv"
"github.com/containerd/errdefs"
dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
dnetwork "github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/docker/docker/libnetwork/iptables"
"github.com/psviderski/uncloud/internal/machine/dns"
"github.com/psviderski/uncloud/internal/machine/firewall"
@@ -27,7 +27,7 @@ func (c *Controller) EnsureUncloudNetwork(ctx context.Context, subnet netip.Pref
needsCreation := false
nw, err := c.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
if err != nil {
if !client.IsErrNotFound(err) {
if !errdefs.IsNotFound(err) {
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
}
needsCreation = true
@@ -187,7 +187,7 @@ func (c *Controller) Cleanup() error {
for _, ctr := range containers {
err = c.client.ContainerStop(ctx, ctr.ID, dockercontainer.StopOptions{})
if err != nil && !client.IsErrNotFound(err) {
if err != nil && !errdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("stop container '%s': %w", ctr.ID, err))
}
@@ -197,7 +197,7 @@ func (c *Controller) Cleanup() error {
})
if err == nil {
removed++
} else if !client.IsErrNotFound(err) {
} else if !errdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("remove container '%s': %w", ctr.ID, err))
}
}
@@ -223,10 +223,10 @@ func (c *Controller) Cleanup() error {
if err = c.client.NetworkRemove(ctx, NetworkName); err == nil {
slog.Info("Docker network removed.", "name", NetworkName)
} else if !client.IsErrNotFound(err) {
} else if !errdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("remove Docker network '%s': %w", NetworkName, err))
}
} else if !client.IsErrNotFound(err) {
} else if !errdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err))
}
+578 -51
View File
@@ -1,19 +1,25 @@
package docker
import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"net/netip"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/containerd/errdefs"
"github.com/distribution/reference"
dockercommand "github.com/docker/cli/cli/command"
dockerconfig "github.com/docker/cli/cli/config"
@@ -25,6 +31,7 @@ import (
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
@@ -40,6 +47,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}$`)
@@ -53,41 +61,35 @@ type Server struct {
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
// address if the address is unknown (e.g. when the machine is not initialised yet).
internalDNSIP func() netip.Addr
// machineID is a function that returns the machine ID. It may return an empty string if the machine
// is not initialised yet.
machineID func() string
// networkReady is a function that returns true if the Docker network is ready for containers.
networkReady func() bool
// waitForNetworkReady is a function that waits for the Docker network to be ready for containers.
waitForNetworkReady func(ctx context.Context) error
}
// ServerOption configures the Docker server.
type ServerOption func(*Server)
// WithNetworkReady sets the network readiness check function.
func WithNetworkReady(networkReady func() bool) ServerOption {
return func(s *Server) {
s.networkReady = networkReady
}
}
// WithWaitForNetworkReady sets the network readiness wait function.
func WithWaitForNetworkReady(waitForNetworkReady func(ctx context.Context) error) ServerOption {
return func(s *Server) {
s.waitForNetworkReady = waitForNetworkReady
}
type ServerOptions struct {
// TODO: verify if we still need the network readiness checks as the cluster controller ensures the network
// is ready before starting the network API server. It may still be needed when communicating with the local
// API server but in this case we should probably fail until the cluster is initialised.
NetworkReady func() bool
WaitForNetworkReady func(ctx context.Context) error
}
// NewServer creates a new Docker gRPC server with the provided Docker service.
func NewServer(service *Service, db *sqlx.DB, internalDNSIP func() netip.Addr, opts ...ServerOption) *Server {
func NewServer(service *Service, db *sqlx.DB, internalDNSIP func() netip.Addr, machineID func() string, opts ServerOptions) *Server {
s := &Server{
client: service.Client,
service: service,
db: db,
internalDNSIP: internalDNSIP,
machineID: machineID,
}
for _, opt := range opts {
opt(s)
}
s.networkReady = opts.NetworkReady
s.waitForNetworkReady = opts.WaitForNetworkReady
return s
}
@@ -115,10 +117,10 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
resp, err := s.client.ContainerCreate(ctx, &config, &hostConfig, &networkConfig, &platform, req.Name)
if err != nil {
if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, err.Error())
if errdefs.IsNotFound(err) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
respBytes, err := json.Marshal(resp)
@@ -133,10 +135,10 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
func (s *Server) InspectContainer(ctx context.Context, req *pb.InspectContainerRequest) (*pb.InspectContainerResponse, error) {
resp, err := s.client.ContainerInspect(ctx, req.Id)
if err != nil {
if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, err.Error())
if errdefs.IsNotFound(err) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
respBytes, err := json.Marshal(resp)
@@ -166,10 +168,10 @@ func (s *Server) StartContainer(ctx context.Context, req *pb.StartContainerReque
}
if err := s.client.ContainerStart(ctx, req.Id, opts); err != nil {
if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, err.Error())
if errdefs.IsNotFound(err) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
return &emptypb.Empty{}, nil
@@ -185,10 +187,10 @@ func (s *Server) StopContainer(ctx context.Context, req *pb.StopContainerRequest
}
if err := s.client.ContainerStop(ctx, req.Id, opts); err != nil {
if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, err.Error())
if errdefs.IsNotFound(err) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
return &emptypb.Empty{}, nil
@@ -218,13 +220,13 @@ func (s *Server) ListContainers(ctx context.Context, req *pb.ListContainersReque
containerSummaries, err := s.client.ContainerList(ctx, opts)
if err != nil {
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
containers := make([]types.ContainerJSON, 0, len(containerSummaries))
containers := make([]container.InspectResponse, 0, len(containerSummaries))
for _, cs := range containerSummaries {
c, err := s.client.ContainerInspect(ctx, cs.ID)
if err != nil {
if client.IsErrNotFound(err) {
if errdefs.IsNotFound(err) {
// The listed container may have been removed while we were inspecting other containers.
continue
}
@@ -257,10 +259,10 @@ func (s *Server) RemoveContainer(ctx context.Context, req *pb.RemoveContainerReq
}
if err := s.client.ContainerRemove(ctx, req.Id, opts); err != nil {
if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, err.Error())
if errdefs.IsNotFound(err) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
return &emptypb.Empty{}, nil
@@ -286,7 +288,7 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
respBody, err := s.client.ImagePull(ctx, req.Image, opts)
if err != nil {
return status.Errorf(codes.Internal, err.Error())
return status.Error(codes.Internal, err.Error())
}
defer respBody.Close()
@@ -317,19 +319,19 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
case err = <-errCh:
return err
case <-ctx.Done():
return status.Errorf(codes.Canceled, ctx.Err().Error())
return status.Error(codes.Canceled, ctx.Err().Error())
}
}
}
// InspectImage returns the image information for the given image ID.
func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest) (*pb.InspectImageResponse, error) {
resp, _, err := s.client.ImageInspectWithRaw(ctx, req.Id)
resp, err := s.client.ImageInspect(ctx, req.Id)
if err != nil {
if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, err.Error())
if errdefs.IsNotFound(err) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
respBytes, err := json.Marshal(resp)
@@ -385,6 +387,49 @@ func (s *Server) InspectRemoteImage(
}, nil
}
// ListImages returns a list of all images matching the filter and indicates whether Docker is using the containerd
// image store.
func (s *Server) ListImages(ctx context.Context, req *pb.ListImagesRequest) (*pb.ListImagesResponse, error) {
var opts image.ListOptions
if len(req.Options) > 0 {
if err := json.Unmarshal(req.Options, &opts); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
}
// Handle filters separately because they implement custom JSON unmarshalling.
var raw map[string]json.RawMessage
if err := json.Unmarshal(req.Options, &raw); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
}
if filtersBytes, ok := raw["Filters"]; ok {
args, err := filters.FromJSON(string(filtersBytes))
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
}
opts.Filters = args
}
}
images, err := s.service.ListImages(ctx, opts)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
machineImages := pb.MachineImages{
ContainerdStore: images.ContainerdStore,
}
if len(images.Images) > 0 {
if machineImages.Images, err = json.Marshal(images.Images); err != nil {
return nil, status.Errorf(codes.Internal, "marshal Docker images: %v", err)
}
}
return &pb.ListImagesResponse{
Messages: []*pb.MachineImages{&machineImages},
}, nil
}
// CreateVolume creates a new volume with the given options.
func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest) (*pb.CreateVolumeResponse, error) {
var opts volume.CreateOptions
@@ -456,7 +501,7 @@ func (s *Server) ListVolumes(ctx context.Context, req *pb.ListVolumesRequest) (*
// RemoveVolume removes a volume with the given ID.
func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest) (*emptypb.Empty, error) {
if err := s.client.VolumeRemove(ctx, req.Id, req.Force); err != nil {
if client.IsErrNotFound(err) {
if errdefs.IsNotFound(err) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Error(codes.Internal, err.Error())
@@ -492,9 +537,21 @@ func (s *Server) CreateServiceContainer(
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
}
envVars := maps.Clone(spec.Container.Env)
if envVars == nil {
envVars = make(api.EnvVars)
}
// Inject the machine ID if available
if s.machineID != nil {
if machineID := s.machineID(); machineID != "" {
envVars["UNCLOUD_MACHINE_ID"] = machineID
}
}
config := &container.Config{
Cmd: spec.Container.Command,
Env: spec.Container.Env.ToSlice(),
Env: envVars.ToSlice(),
Entrypoint: spec.Container.Entrypoint,
Hostname: containerName,
Image: spec.Container.Image,
@@ -557,6 +614,7 @@ func (s *Server) CreateServiceContainer(
NanoCPUs: spec.Container.Resources.CPU,
Memory: spec.Container.Resources.Memory,
MemoryReservation: spec.Container.Resources.MemoryReservation,
DeviceRequests: spec.Container.Resources.DeviceReservations,
},
// 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.
@@ -590,12 +648,19 @@ func (s *Server) CreateServiceContainer(
resp, err := s.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, containerName)
if err != nil {
if client.IsErrNotFound(err) {
if errdefs.IsNotFound(err) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Error(codes.Internal, err.Error())
}
// Inject configs into the created container
if err = s.injectConfigs(ctx, resp.ID, spec.Configs, spec.Container.ConfigMounts); err != nil {
// Remove the container if config injection fails
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
return nil, status.Errorf(codes.Internal, "inject configs: %v", err)
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
@@ -672,6 +737,122 @@ func ToDockerMounts(volumes []api.VolumeSpec, mounts []api.VolumeMount) ([]mount
return dockerMounts, nil
}
// injectConfigs writes config content directly into the container.
// It processes ConfigSpecs and ConfigMounts to mount configuration content into the container filesystem.
func (s *Server) injectConfigs(ctx context.Context, containerID string, configs []api.ConfigSpec, mounts []api.ConfigMount) error {
if len(configs) == 0 || len(mounts) == 0 {
return nil
}
if err := api.ValidateConfigsAndMounts(configs, mounts); err != nil {
return fmt.Errorf("validate configs and mounts: %w", err)
}
// Create a map of config name to config spec for quick lookup
configMap := make(map[string]api.ConfigSpec)
for _, config := range configs {
configMap[config.Name] = config
}
// Process each config mount
for _, m := range mounts {
config, exists := configMap[m.ConfigName]
if !exists {
return fmt.Errorf("config mount references a config that doesn't exist: '%s'", m.ConfigName)
}
// Determine target path in container
targetPath := m.ContainerPath
if targetPath == "" {
// This is the default from the Compose spec
targetPath = filepath.Join("/", m.ConfigName)
}
// Determine file mode
fileMode := os.FileMode(0o444) // Default permissions
if m.Mode != nil {
fileMode = *m.Mode
}
uid, err := m.GetNumericUid()
if err != nil {
return fmt.Errorf("invalid Uid: %w", err)
}
gid, err := m.GetNumericGid()
if err != nil {
return fmt.Errorf("invalid Gid: %w", err)
}
// Copy the config content directly into the container
if err := s.copyContentToContainer(
ctx, containerID, config.Content, targetPath, uid, gid, fileMode,
); err != nil {
return fmt.Errorf("copy config file '%s' to container: %w", config.Name, err)
}
slog.Debug("Injected config into container",
"config", config.Name,
"container", containerID[:12],
"target", targetPath)
}
return nil
}
// copyContentToContainer copies content directly to a file in the container using Docker's CopyToContainer API.
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
header := &tar.Header{
Name: filepath.Base(targetPath),
Size: int64(len(content)),
Mode: int64(fileMode),
ModTime: time.Now(),
Typeflag: tar.TypeReg,
}
// Set ownership if specified
if uid != nil {
header.Uid = int(*uid)
}
if gid != nil {
header.Gid = int(*gid)
}
// Write header and content to tar archive
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header: %w", err)
}
if _, err := tw.Write(content); err != nil {
return fmt.Errorf("write content to tar: %w", err)
}
if err := tw.Close(); err != nil {
return fmt.Errorf("close tar writer: %w", err)
}
// Copy the tar archive to the container
targetDir := filepath.Dir(targetPath)
if targetDir == "." {
targetDir = "/"
}
if err := s.client.CopyToContainer(
ctx,
containerID,
targetDir,
&buf,
container.CopyToContainerOptions{CopyUIDGID: true},
); err != nil {
return fmt.Errorf("copy to container: %w", err)
}
return nil
}
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
if opts == nil {
return nil
@@ -703,7 +884,7 @@ func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mo
// TODO: non-local volume drivers should likely be handled differently (needs proper investigation).
if _, err := s.client.VolumeInspect(ctx, m.Source); err != nil {
if client.IsErrNotFound(err) {
if errdefs.IsNotFound(err) {
return status.Errorf(codes.NotFound, "volume '%s' not found", m.Source)
}
return status.Errorf(codes.Internal, "inspect volume '%s': %v", m.Source, err.Error())
@@ -723,10 +904,10 @@ func (s *Server) InspectServiceContainer(
) (*pb.ServiceContainer, error) {
serviceCtr, err := s.service.InspectServiceContainer(ctx, req.Id)
if err != nil {
if client.IsErrNotFound(err) {
return nil, status.Errorf(codes.NotFound, err.Error())
if errdefs.IsNotFound(err) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Error(codes.Internal, err.Error())
}
ctrBytes, err := json.Marshal(serviceCtr.Container)
@@ -813,7 +994,7 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
if !fullDockerIDRegex.MatchString(req.Id) {
ctr, err := s.client.ContainerInspect(ctx, req.Id)
if err != nil {
if client.IsErrNotFound(err) {
if errdefs.IsNotFound(err) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Error(codes.Internal, err.Error())
@@ -834,3 +1015,349 @@ 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()
if err != nil {
return nil, api.ExecOptions{}, status.Errorf(codes.InvalidArgument, "receive config: %v", err)
}
execConfig := req.GetConfig()
if execConfig == nil {
return nil, api.ExecOptions{}, status.Error(codes.InvalidArgument, "first message must contain exec config")
}
// Unmarshal the Uncloud's execOpts
var execOpts api.ExecOptions
if err := json.Unmarshal(execConfig.Options, &execOpts); err != nil {
return nil, api.ExecOptions{}, status.Errorf(codes.InvalidArgument, "unmarshal exec config: %v", err)
}
return execConfig, execOpts, nil
}
// handleServerExecInput reads from the gRPC stream and writes to Docker stdin, handling resize requests.
func (s *Server) handleServerExecInput(
ctx context.Context,
stream pb.Docker_ExecContainerServer,
attachConn types.HijackedResponse,
execID string,
tty bool,
) error {
slog.Debug("Input goroutine started", "exec_id", execID, "tty", tty)
defer slog.Debug("Input goroutine exited", "exec_id", execID)
defer attachConn.CloseWrite()
for {
select {
case <-ctx.Done():
slog.Debug("Input goroutine context canceled via context", "exec_id", execID)
return nil
default:
}
req, err := stream.Recv()
switch {
case errors.Is(err, io.EOF):
slog.Debug("Input goroutine received EOF", "exec_id", execID)
return nil
case status.Code(err) == codes.Canceled:
// Can be the case when the output goroutine ends and the stream context is canceled.
slog.Debug("Input goroutine context canceled", "exec_id", execID)
return nil
case err == nil:
// continue processing
default:
return fmt.Errorf("receive from stream: %w", err)
}
switch payload := req.Payload.(type) {
case *pb.ExecContainerRequest_Stdin:
if _, err := attachConn.Conn.Write(payload.Stdin); err != nil {
return fmt.Errorf("write to stdin: %w", err)
}
case *pb.ExecContainerRequest_Resize:
if tty {
resizeOpts := container.ResizeOptions{
Height: uint(payload.Resize.Height),
Width: uint(payload.Resize.Width),
}
if err := s.client.ContainerExecResize(ctx, execID, resizeOpts); err != nil {
slog.Warn("Failed to resize TTY", "err", err, "exec_id", execID)
}
}
}
}
}
// handleServerExecOutput reads from Docker stdout/stderr and writes to the gRPC stream.
func (s *Server) handleServerExecOutput(
stream pb.Docker_ExecContainerServer,
attachResp types.HijackedResponse,
execID string,
tty bool,
) error {
slog.Debug("Output goroutine started", "exec_id", execID, "tty", tty)
defer slog.Debug("Output goroutine exited", "exec_id", execID)
if tty {
// In TTY mode, all output is stdout - copy directly to stream
stdoutWriter := &grpcStreamWriter{stream: stream, isStderr: false}
_, err := io.Copy(stdoutWriter, attachResp.Reader)
if err != nil && err != io.EOF {
return fmt.Errorf("copy tty output: %w", err)
}
return nil
} else {
// In non-TTY mode, Docker multiplexes stdout/stderr with headers
// Use stdcopy to demultiplex
slog.Debug("Starting StdCopy for non-TTY", "exec_id", execID)
stdoutWriter := &grpcStreamWriter{stream: stream, isStderr: false}
stderrWriter := &grpcStreamWriter{stream: stream, isStderr: true}
written, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, attachResp.Reader)
slog.Debug("StdCopy completed", "exec_id", execID, "bytes", written, "err", err)
if err != nil && err != io.EOF {
return fmt.Errorf("demultiplex docker output: %w", err)
}
return nil
}
}
// grpcStreamWriter is a writer that sends data to a gRPC stream as stdout or stderr.
type grpcStreamWriter struct {
stream pb.Docker_ExecContainerServer
isStderr bool
}
func (w *grpcStreamWriter) Write(p []byte) (n int, err error) {
data := make([]byte, len(p))
copy(data, p)
var resp *pb.ExecContainerResponse
if w.isStderr {
resp = &pb.ExecContainerResponse{
Payload: &pb.ExecContainerResponse_Stderr{Stderr: data},
}
} else {
resp = &pb.ExecContainerResponse{
Payload: &pb.ExecContainerResponse_Stdout{Stdout: data},
}
}
if err := w.stream.Send(resp); err != nil {
return 0, err
}
return len(p), nil
}
// ExecContainer executes a command in a running container with bidirectional streaming for stdin/stdout/stderr.
func (s *Server) ExecContainer(stream pb.Docker_ExecContainerServer) error {
slog.Debug("ExecContainer server-side called")
defer slog.Debug("ExecContainer server-side ended")
ctx := stream.Context()
// Receive and validate configuration
execConfig, execOpts, err := s.receiveExecConfig(stream)
if err != nil {
return err
}
// Convert to Docker's ExecOptions
dockerExecOpts := container.ExecOptions{
Cmd: execOpts.Command,
AttachStdin: execOpts.AttachStdin,
AttachStdout: execOpts.AttachStdout,
AttachStderr: execOpts.AttachStderr,
Tty: execOpts.Tty,
User: execOpts.User,
Privileged: execOpts.Privileged,
WorkingDir: execOpts.WorkingDir,
Env: execOpts.Env,
}
// Create the exec instance
execResp, err := s.client.ContainerExecCreate(ctx, execConfig.ContainerId, dockerExecOpts)
if err != nil {
if errdefs.IsNotFound(err) {
return status.Error(codes.NotFound, err.Error())
}
return status.Errorf(codes.Internal, "create exec: %v", err)
}
// Send the exec ID back to the client
if err := stream.Send(&pb.ExecContainerResponse{
Payload: &pb.ExecContainerResponse_ExecId{ExecId: execResp.ID},
}); err != nil {
return status.Errorf(codes.Internal, "send exec ID: %v", err)
}
slog.Debug("Sent exec ID to the client", "exec_id", execResp.ID)
// For detached mode, start without attaching
if execOpts.Detach {
dockerStartOpts := container.ExecStartOptions{
Tty: dockerExecOpts.Tty,
Detach: true,
}
if err := s.client.ContainerExecStart(ctx, execResp.ID, dockerStartOpts); err != nil {
return status.Errorf(codes.Internal, "start exec: %v", err)
}
return nil
}
// For attached mode, attach to the exec instance
attachOpts := container.ExecAttachOptions{
Tty: dockerExecOpts.Tty,
}
attachConn, err := s.client.ContainerExecAttach(ctx, execResp.ID, attachOpts)
if err != nil {
return status.Errorf(codes.Internal, "attach to exec: %v", err)
}
defer attachConn.Close()
// Create a cancelable context for the input handler
handlerCtx, cancelInput := context.WithCancel(ctx)
defer cancelInput()
// Create a channel to wait for output completion
outputDone := make(chan error, 1)
// Start stdin handler if stdin is attached
if dockerExecOpts.AttachStdin {
go func() {
err := s.handleServerExecInput(handlerCtx, stream, attachConn, execResp.ID, dockerExecOpts.Tty)
if err != nil {
slog.Warn("Error in exec input handler", "err", err, "exec_id", execResp.ID)
}
}()
} else {
// If not attaching stdin, close the write side immediately
attachConn.CloseWrite()
}
// Start output handler
// We only wait for this goroutine to complete - it signals when the exec process finishes
go func() {
outputDone <- s.handleServerExecOutput(stream, attachConn, execResp.ID, dockerExecOpts.Tty)
}()
// Wait for the output goroutine to complete (it signals when done)
// We only wait for the output handler goroutine, not for the stdin one.
if err := <-outputDone; err != nil {
slog.Warn("Error in exec output handler", "err", err, "exec_id", execResp.ID)
}
// Do a best-effort cancellation of the stdin handler.
// We can't guarantee immediate exit because it may be blocked on stream.Recv(), but at
// least we want to send a cancel signal explicitly.
cancelInput()
inspectResp, err := s.client.ContainerExecInspect(ctx, execResp.ID)
if err != nil {
slog.Error("Failed to inspect exec after completion", "err", err, "exec_id", execResp.ID)
return status.Errorf(codes.Internal, "inspect exec: %v", err)
}
// Send the exit code
slog.Debug("Sending exec exit code", "exec_id", execResp.ID, "exit_code", inspectResp.ExitCode)
if err := stream.Send(&pb.ExecContainerResponse{
Payload: &pb.ExecContainerResponse_ExitCode{ExitCode: int32(inspectResp.ExitCode)},
}); err != nil {
slog.Error("Failed to send exec exit code", "err", err, "exec_id", execResp.ID)
return status.Errorf(codes.Internal, "send exit code: %v", err)
}
return nil
}
+155 -1
View File
@@ -1,24 +1,34 @@
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"
"google.golang.org/grpc/status"
)
// Service provides higher-level Docker operations that extends Docker API with Uncloud-specific data
// from the machine database.
type Service struct {
// Client is a Docker client for managing Docker resources.
Client *client.Client
// db is a connection to the machine database.
db *sqlx.DB
}
@@ -43,7 +53,7 @@ func (s *Service) InspectServiceContainer(ctx context.Context, nameOrID string)
return serviceCtr, fmt.Errorf("container '%s' is not managed by Uncloud", nameOrID)
}
serviceCtr.Container = api.Container{ContainerJSON: ctr}
serviceCtr.Container = api.Container{InspectResponse: ctr}
// Retrieve ServiceSpec from the machine database.
var specBytes []byte
@@ -101,3 +111,147 @@ func (s *Service) ListServiceContainers(
return containers, nil
}
// IsContainerdImageStoreEnabled checks if Docker is configured to use the containerd image store:
// https://docs.docker.com/engine/storage/containerd/
func (s *Service) IsContainerdImageStoreEnabled(ctx context.Context) (bool, error) {
info, err := s.Client.Info(ctx)
if err != nil {
return false, fmt.Errorf("get Docker info: %w", err)
}
return strings.Contains(fmt.Sprintf("%s", info.DriverStatus), "containerd.snapshotter"), nil
}
type Images struct {
// Images is a list of images present in the Docker image store (either internal or containerd).
Images []image.Summary
// ContainerdStore indicates whether Docker is using the containerd image store.
ContainerdStore bool
}
// ListImages lists Docker images with the given options and indicates whether Docker is using the containerd
// image store. It always includes image manifests in the response if the store is containerd.
func (s *Service) ListImages(ctx context.Context, opts image.ListOptions) (Images, error) {
var imagesResp Images
// Always include the image manifests in the response.
opts.Manifests = true
images, err := s.Client.ImageList(ctx, opts)
if err != nil {
return imagesResp, status.Errorf(codes.Internal, "list images: %v", err)
}
isContainerdStore, err := s.IsContainerdImageStoreEnabled(ctx)
if err != nil {
return imagesResp, status.Errorf(codes.Internal, "check if Docker uses containerd image store: %v", err)
}
imagesResp = Images{
Images: images,
ContainerdStore: isContainerdStore,
}
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()
}
}
+5 -2
View File
@@ -1,9 +1,12 @@
package firewall
import "fmt"
import (
"fmt"
"net/netip"
)
// ConfigureIptablesChains is a stub for Darwin.
func ConfigureIptablesChains() error {
func ConfigureIptablesChains(machineIP netip.Addr) error {
return fmt.Errorf("not supported on Darwin")
}
+21 -6
View File
@@ -3,6 +3,7 @@ package firewall
import (
"fmt"
"log/slog"
"net/netip"
"strconv"
"strings"
@@ -18,7 +19,7 @@ const (
)
// ConfigureIptablesChains sets up custom iptables chains and initial firewall rules for Uncloud networking.
func ConfigureIptablesChains() error {
func ConfigureIptablesChains(machineIP netip.Addr) error {
if err := createIptablesChains(); err != nil {
return err
}
@@ -27,10 +28,24 @@ func ConfigureIptablesChains() error {
ipt6 := iptables.GetIptable(iptables.IPv6)
// Allow WireGuard traffic to the machine.
acceptWireGuardRule := []string{"-p", "udp", "--dport", strconv.Itoa(network.WireGuardPort), "-j", "ACCEPT"}
err := ipt4.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, acceptWireGuardRule)
if err != nil {
return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(acceptWireGuardRule, " "), err)
acceptWireGuardRule := []string{
"-p", "udp",
"--dport", strconv.Itoa(network.WireGuardPort),
"-j", "ACCEPT",
}
// Allow cluster machines to access the unregistry (embedded image registry) on the machine to push/pull images.
// TODO: allow access only from the machine IPs (10.210.N.1) but not the containers running on them. Use ipset?
acceptUnregistryRule := []string{
"-i", network.WireGuardInterfaceName,
"-d", machineIP.String(),
"-p", "tcp",
"--dport", strconv.Itoa(constants.UnregistryPort),
"-j", "ACCEPT",
}
for _, rule := range [][]string{acceptUnregistryRule, acceptWireGuardRule} {
if err := ipt4.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(rule, " "), err)
}
}
// Allow cluster machines to access Machine API via the management IPv6 WireGuard network.
@@ -50,7 +65,7 @@ func ConfigureIptablesChains() error {
"-j", "ACCEPT",
}
for _, rule := range [][]string{acceptMachineAPIRule, acceptCorrosionGossipRule} {
if err = ipt6.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
if err := ipt6.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
return fmt.Errorf("insert ip6tables rule '%s': %w", strings.Join(rule, " "), err)
}
}
+73 -6
View File
@@ -30,6 +30,7 @@ import (
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
"github.com/psviderski/uncloud/internal/machine/network"
"github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/unregistry"
"github.com/siderolabs/grpc-proxy/proxy"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
@@ -63,6 +64,8 @@ type Config struct {
// DockerClient manages system and user containers using the local Docker daemon.
DockerClient *client.Client
// ContainerdSockPath is the path to the containerd.sock used by Docker.
ContainerdSockPath string
// CaddyConfigDir specifies the directory where the machine generates the Caddy reverse proxy configuration file
// for routing external traffic to service containers across the internal network. Default is DataDir/caddy.
@@ -93,6 +96,26 @@ func (c *Config) SetDefaults() (*Config, error) {
}
cfg.DockerClient = cli
}
if cfg.ContainerdSockPath == "" {
// Auto-detect the containerd.sock path used by Docker.
paths := []string{
"/run/containerd/containerd.sock", // Default path on most Linux distributions.
"/run/docker/containerd/containerd.sock",
"/var/run/containerd/containerd.sock",
"/var/run/docker/containerd/containerd.sock",
}
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
cfg.ContainerdSockPath = path
slog.Debug("Detected containerd socket used by Docker.", "path", path)
break
}
}
if cfg.ContainerdSockPath == "" {
slog.Warn("Failed to auto-detect containerd socket used by Docker.")
}
}
if cfg.CorrosionDir == "" {
cfg.CorrosionDir = filepath.Join(cfg.DataDir, "corrosion")
@@ -231,7 +254,6 @@ func NewMachine(config *Config) (*Machine, error) {
if err != nil {
return nil, fmt.Errorf("init machine database: %w", err)
}
dockerService := machinedocker.NewService(config.DockerClient, db)
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
@@ -260,9 +282,14 @@ func NewMachine(config *Config) (*Machine, error) {
internalDNSIP := func() netip.Addr {
return m.IP()
}
m.dockerServer = machinedocker.NewServer(dockerService, db, internalDNSIP,
machinedocker.WithNetworkReady(m.IsNetworkReady),
machinedocker.WithWaitForNetworkReady(m.WaitForNetworkReady))
// Machine ID will only be available after the machine is initialised as a cluster member so wrap it in a function.
machineID := func() string {
return m.state.ID
}
m.dockerServer = machinedocker.NewServer(dockerService, db, internalDNSIP, machineID, machinedocker.ServerOptions{
NetworkReady: m.IsNetworkReady,
WaitForNetworkReady: m.WaitForNetworkReady,
})
caddyServer := caddyconfig.NewServer(caddyconfig.NewService(config.CaddyConfigDir))
m.localMachineServer = newGRPCServer(m, c, m.dockerServer, caddyServer)
@@ -403,11 +430,43 @@ func (m *Machine) Run(ctx context.Context) error {
}
dnsResolver := dns.NewClusterResolver(m.store)
dnsServer, err := dns.NewServer(m.IP(), dnsResolver, m.config.DNSUpstreams)
dnsServer, err := dns.NewServer(
m.IP(),
m.state.Network.Subnet,
dnsResolver,
m.config.DNSUpstreams,
)
if err != nil {
return fmt.Errorf("create embedded DNS server: %w", err)
}
var unreg *unregistry.Registry
if m.config.ContainerdSockPath != "" {
isContainerdStore, err := m.dockerService.IsContainerdImageStoreEnabled(ctx)
if err != nil {
return fmt.Errorf("check if Docker uses containerd image store: %w", err)
}
if isContainerdStore {
// Create an embedded container registry listening on the machine IP address and
// using the local Docker (containerd) image store as its backend.
unreg, err = unregistry.NewRegistry(unregistry.Config{
Addr: net.JoinHostPort(m.IP().String(), strconv.Itoa(constants.UnregistryPort)),
ContainerdNamespace: "moby",
ContainerdSock: m.config.ContainerdSockPath,
LogFormatter: "text",
LogLevel: "info",
})
if err != nil {
return fmt.Errorf("create embedded registry: %w", err)
}
} else {
slog.Warn("Skipping embedded unregistry setup as Docker is not using the containerd image store.")
}
} else {
slog.Warn("Skipping embedded unregistry setup as the containerd socket path is not configured.")
}
m.mu.Lock()
m.clusterCtrl, err = newClusterController(
m.state,
@@ -419,6 +478,7 @@ func (m *Machine) Run(ctx context.Context) error {
caddyconfigCtrl,
dnsServer,
dnsResolver,
unreg,
)
m.mu.Unlock()
if err != nil {
@@ -761,7 +821,14 @@ func (m *Machine) JoinCluster(_ context.Context, req *pb.JoinClusterRequest) (*e
if err := m.state.Save(); err != nil {
return nil, status.Errorf(codes.Internal, "save machine state: %v", err)
}
slog.Info("Machine configured to join the cluster.", "id", m.state.ID, "name", m.state.Name)
slog.Info(
"Machine configured to join the cluster.",
"id", m.state.ID,
"name", m.state.Name,
"subnet", m.state.Network.Subnet.String(),
"management_ip", m.state.Network.ManagementIP.String(),
"peers", len(m.state.Network.Peers),
)
// Signal that the machine is initialised as a member of a cluster.
m.initialised <- struct{}{}
+1 -1
View File
@@ -79,7 +79,7 @@ func (n *WireGuardNetwork) Configure(config Config) error {
if err := n.configureDevice(config); err != nil {
return err
}
slog.Info("Configured WireGuard interface.", "name", n.link.Attrs().Name)
slog.Info("Configured WireGuard interface.", "name", n.link.Attrs().Name, "peers", len(n.peers))
managementPrefix, err := addrToSingleIPPrefix(config.ManagementIP)
if err != nil {
+1 -1
View File
@@ -19,7 +19,7 @@ CREATE TABLE machines
CREATE TABLE containers
(
id TEXT NOT NULL PRIMARY KEY,
-- container is a JSON-serialized api.Container struct.
-- container is a JSON-serialized api.ServiceContainer struct.
container TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(container)),
machine_id TEXT NOT NULL DEFAULT '',
service_id TEXT AS (json_extract(container, '$.Config.Labels."uncloud.service.id"')),
+132
View File
@@ -0,0 +1,132 @@
package proxy
import (
"context"
"fmt"
"io"
"net"
"os"
"sync"
"time"
)
// Proxy proxies local connections to a remote TCP address optionally using a custom dialer.
type Proxy struct {
Listener net.Listener
RemoteAddr string
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
OnError func(error)
activeConns sync.WaitGroup
}
// deadliner is an interface for listeners that support setting deadlines.
type deadliner interface {
SetDeadline(t time.Time) error
}
// halfCloser is an interface for connections that support half-close.
type halfCloser interface {
CloseWrite() error
}
// Run starts the proxy and runs until the context is canceled.
func (p *Proxy) Run(ctx context.Context) {
if p.DialContext == nil {
p.DialContext = (&net.Dialer{}).DialContext
}
defer p.Listener.Close()
// Handle incoming connections until context is canceled.
Loop:
for {
select {
case <-ctx.Done():
break Loop
default:
}
// Set a deadline on the listener if supported to check context periodically.
if dl, ok := p.Listener.(deadliner); ok {
dl.SetDeadline(time.Now().Add(1 * time.Second))
}
conn, err := p.Listener.Accept()
if err != nil {
if os.IsTimeout(err) {
// Just a timeout, continue to check context and accept again.
continue
}
select {
case <-ctx.Done():
break Loop
default:
if p.OnError != nil {
p.OnError(fmt.Errorf("accept local connection: %w", err))
}
continue
}
}
p.activeConns.Add(1)
go p.handleConnection(ctx, conn)
}
// Wait for all connections to finish.
p.activeConns.Wait()
}
func (p *Proxy) handleConnection(ctx context.Context, localConn net.Conn) {
defer p.activeConns.Done()
defer localConn.Close()
// Use a separate context with timeout for dialing the remote address.
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
remoteConn, err := p.DialContext(dialCtx, "tcp", p.RemoteAddr)
if err != nil {
if p.OnError != nil {
p.OnError(fmt.Errorf("connect remote address '%s': %w", p.RemoteAddr, err))
}
return
}
defer remoteConn.Close()
// Bidirectional copy with proper half-close handling.
done := make(chan error, 2)
go func() {
_, err := io.Copy(remoteConn, localConn)
// Close write half of remote connection if supported.
if hc, ok := remoteConn.(halfCloser); ok {
hc.CloseWrite()
}
done <- err
}()
go func() {
_, err := io.Copy(localConn, remoteConn)
// Close write half of local connection if supported.
if hc, ok := localConn.(halfCloser); ok {
hc.CloseWrite()
}
done <- err
}()
// Wait for both copies to complete or context cancel.
for i := 0; i < 2; i++ {
select {
case <-ctx.Done():
// Close connections to abort ongoing copies.
localConn.Close()
remoteConn.Close()
return
case err = <-done:
if err != nil && p.OnError != nil {
p.OnError(fmt.Errorf("data copy: %w", err))
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
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 && r.port != 22 {
args = append(args, "-p", strconv.Itoa(r.port))
}
if r.keyPath != "" {
args = append(args, "-i", r.keyPath)
}
args = append(args, r.user+"@"+r.host)
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
}
+76
View File
@@ -0,0 +1,76 @@
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: "default port, no key",
user: "root",
host: "example.com",
port: 22,
keyPath: "",
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"},
},
{
name: "custom port",
user: "ubuntu",
host: "192.168.1.10",
port: 2222,
keyPath: "",
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "ubuntu@192.168.1.10"},
},
{
name: "with key path",
user: "root",
host: "example.com",
port: 22,
keyPath: "/path/to/key",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@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"},
},
{
name: "port zero (default)",
user: "root",
host: "example.com",
port: 0,
keyPath: "",
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"},
},
}
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)
})
}
}
+3 -3
View File
@@ -8,10 +8,10 @@ import (
"time"
"github.com/cenkalti/backoff/v4"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
dockerclient "github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
"github.com/psviderski/uncloud/internal/machine"
"github.com/psviderski/uncloud/internal/machine/api/pb"
@@ -142,7 +142,7 @@ func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error
// Join the rest of the machines to the cluster.
for _, m := range machines[1:] {
if err = WaitMachineReady(ctx, m, 5*time.Second); err != nil {
if err = WaitMachineReady(ctx, m, 30*time.Second); err != nil {
return fmt.Errorf("wait for machine %q to be ready: %w", m.Name, err)
}
@@ -200,7 +200,7 @@ func (p *Provisioner) InspectCluster(ctx context.Context, name string) (Cluster,
// Docker network name is the same as the cluster name.
net, err := p.dockerCli.NetworkInspect(ctx, name, network.InspectOptions{})
if err != nil {
if dockerclient.IsErrNotFound(err) {
if errdefs.IsNotFound(err) {
return c, ErrNotFound
}
return c, fmt.Errorf("inspect Docker network '%s': %w", name, err)
+2 -2
View File
@@ -10,9 +10,9 @@ import (
"time"
"github.com/cenkalti/backoff/v4"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
dockerclient "github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/client"
@@ -137,7 +137,7 @@ func (p *Provisioner) createContainerWithImagePull(
return resp, nil
}
if !dockerclient.IsErrNotFound(err) {
if !errdefs.IsNotFound(err) {
return resp, fmt.Errorf("create Docker container: %w", err)
}
+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`.
+3 -27
View File
@@ -2,13 +2,10 @@ package api
import (
"context"
"fmt"
"slices"
"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 {
@@ -28,6 +25,7 @@ type ContainerClient interface {
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)
}
type DNSClient interface {
@@ -50,6 +48,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 {
@@ -57,27 +57,3 @@ type VolumeClient interface {
ListVolumes(ctx context.Context, filter *VolumeFilter) ([]MachineVolume, error)
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) {
machines, err := cli.ListMachines(ctx, nil)
if err != nil {
return nil, nil, fmt.Errorf("list machines: %w", err)
}
var proxiedMachines MachineMembersList
md := metadata.New(nil)
for _, m := range machines {
if len(namesOrIDs) == 0 ||
slices.Contains(namesOrIDs, m.Machine.Name) || slices.Contains(namesOrIDs, m.Machine.Id) {
proxiedMachines = append(proxiedMachines, m)
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
md.Append("machines", machineIP.String())
}
}
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
}
+125
View File
@@ -0,0 +1,125 @@
// Implementation of Config feature from the Compose spec
package api
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strconv"
)
// ConfigSpec defines a configuration object that can be mounted into containers
type ConfigSpec struct {
Name string
// Content of the config when specified inline
Content []byte `json:",omitempty"`
// Note: NOT IMPLEMENTED
// External indicates this config already exists and should not be created
// External bool `json:",omitempty"`
// Note: NOT IMPLEMENTED
// Labels for the config
// Labels map[string]string `json:",omitempty"`
// TODO: add support for "environment"
}
func (c *ConfigSpec) Validate() error {
if c.Name == "" {
return fmt.Errorf("config name is required")
}
return nil
}
// Equals compares two ConfigSpec instances
func (c *ConfigSpec) Equals(other ConfigSpec) bool {
return c.Name == other.Name &&
bytes.Equal(c.Content, other.Content)
}
// ConfigMount defines how a config is mounted into a container
type ConfigMount struct {
// ConfigName references a config defined in ServiceSpec.Configs by its Name field
ConfigName string
// ContainerPath is the absolute path where the config is mounted in the container
ContainerPath string `json:",omitempty"`
// Uid for the mounted config file
Uid string `json:",omitempty"`
// Gid for the mounted config file
Gid string `json:",omitempty"`
// Mode (file permissions) for the mounted config file
Mode *os.FileMode `json:",omitempty"`
}
func (c *ConfigMount) GetNumericUid() (*uint64, error) {
if c.Uid == "" {
return nil, nil
}
uid, err := strconv.ParseUint(c.Uid, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid Uid '%s': %w", c.Uid, err)
}
if int(uid) < 0 {
return nil, fmt.Errorf("invalid Uid '%s': value too high", c.Uid)
}
return &uid, nil
}
func (c *ConfigMount) GetNumericGid() (*uint64, error) {
if c.Gid == "" {
return nil, nil
}
gid, err := strconv.ParseUint(c.Gid, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid Gid '%s': %w", c.Gid, err)
}
if int(gid) < 0 {
return nil, fmt.Errorf("invalid Gid '%s': value too high", c.Gid)
}
return &gid, nil
}
func (c *ConfigMount) Validate() error {
if c.ConfigName == "" {
return fmt.Errorf("config mount source is required")
}
if _, err := c.GetNumericUid(); err != nil {
return err
}
if _, err := c.GetNumericGid(); err != nil {
return err
}
if c.ContainerPath != "" && !filepath.IsAbs(c.ContainerPath) {
return fmt.Errorf("container path must be absolute")
}
return nil
}
// 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{})
for _, cfg := range configs {
if err := cfg.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
if _, ok := configMap[cfg.Name]; ok {
return fmt.Errorf("duplicate config name: '%s'", cfg.Name)
}
configMap[cfg.Name] = struct{}{}
}
for _, mount := range mounts {
if err := mount.Validate(); err != nil {
return fmt.Errorf("invalid config mount: %w", err)
}
if _, exists := configMap[mount.ConfigName]; !exists {
return fmt.Errorf("config mount source '%s' does not refer to any defined config", mount.ConfigName)
}
}
return nil
}
+294
View File
@@ -0,0 +1,294 @@
package api
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// uint64Ptr is a convenience function to create a pointer to a uint64 value
func uint64Ptr(v uint64) *uint64 {
return &v
}
func TestConfigMount_GetNumericUid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
uid string
expected *uint64
wantErr string
}{
{
name: "empty uid returns nil",
uid: "",
expected: nil,
},
{
name: "valid numeric uid",
uid: "1000",
expected: uint64Ptr(1000),
},
{
name: "zero uid",
uid: "0",
expected: uint64Ptr(0),
},
{
name: "invalid non-numeric uid",
uid: "root",
wantErr: "invalid Uid 'root'",
},
{
name: "negative uid",
uid: "-1",
wantErr: "invalid Uid",
},
{
name: "very large uid",
uid: "18446744073709551615", // max uint64
wantErr: "invalid Uid '18446744073709551615': value too high",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mount := &ConfigMount{Uid: tt.uid}
uid, err := mount.GetNumericUid()
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
assert.Nil(t, uid)
return
}
require.NoError(t, err)
if tt.expected == nil {
assert.Nil(t, uid)
} else {
require.NotNil(t, uid)
assert.Equal(t, *tt.expected, *uid)
}
})
}
}
func TestConfigMount_GetNumericGid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
gid string
expected *uint64
wantErr string
}{
{
name: "empty gid returns nil",
gid: "",
expected: nil,
},
{
name: "valid numeric gid",
gid: "1000",
expected: uint64Ptr(1000),
},
{
name: "zero gid",
gid: "0",
expected: uint64Ptr(0),
},
{
name: "invalid non-numeric gid",
gid: "wheel",
wantErr: "invalid Gid 'wheel'",
},
{
name: "negative gid",
gid: "-1",
wantErr: "invalid Gid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mount := &ConfigMount{Gid: tt.gid}
gid, err := mount.GetNumericGid()
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
assert.Nil(t, gid)
return
}
require.NoError(t, err)
if tt.expected == nil {
assert.Nil(t, gid)
} else {
require.NotNil(t, gid)
assert.Equal(t, *tt.expected, *gid)
}
})
}
}
func TestValidateConfigsAndMounts(t *testing.T) {
t.Parallel()
mode := os.FileMode(0o644)
tests := []struct {
name string
configs []ConfigSpec
mounts []ConfigMount
wantErr string
}{
{
name: "empty configs and mounts",
configs: []ConfigSpec{},
mounts: []ConfigMount{},
},
{
name: "valid configs without mounts",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
{Name: "config2", Content: []byte("content2")},
},
mounts: []ConfigMount{},
},
{
name: "valid configs with valid mounts",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
{Name: "config2", Content: []byte("content2")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: "/etc/config1"},
{ConfigName: "config2", ContainerPath: "/etc/config2", Uid: "1000", Gid: "1000"},
},
},
{
name: "config with empty name",
configs: []ConfigSpec{
{Name: "", Content: []byte("content")},
},
mounts: []ConfigMount{},
wantErr: "config name is required",
},
{
name: "duplicate config names",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
{Name: "config1", Content: []byte("content2")},
},
mounts: []ConfigMount{},
wantErr: "duplicate config name: 'config1'",
},
{
name: "mount with empty config name",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "", ContainerPath: "/etc/config"},
},
wantErr: "config mount source is required",
},
{
name: "mount referencing non-existent config",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "nonexistent", ContainerPath: "/etc/config"},
},
wantErr: "config mount source 'nonexistent' does not refer to any defined config",
},
{
name: "mount with invalid uid",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: "/etc/config", Uid: "invalid"},
},
wantErr: "invalid Uid 'invalid'",
},
{
name: "mount with invalid gid",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: "/etc/config", Gid: "invalid"},
},
wantErr: "invalid Gid 'invalid'",
},
{
name: "mount with relative container path",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: "relative/path"},
},
wantErr: "container path must be absolute",
},
{
name: "mount with empty container path",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: ""},
},
// Empty path is allowed
},
{
name: "mount with absolute container path",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: "/absolute/path"},
},
},
{
name: "complex valid scenario",
configs: []ConfigSpec{
{Name: "nginx-conf", Content: []byte("server { listen 80; }")},
{Name: "app-config", Content: []byte("debug=true")},
{Name: "cert", Content: []byte("-----BEGIN CERTIFICATE-----")},
},
mounts: []ConfigMount{
{ConfigName: "nginx-conf", ContainerPath: "/etc/nginx/nginx.conf", Uid: "0", Gid: "0", Mode: &mode},
{ConfigName: "app-config", ContainerPath: "/app/config.env"},
{ConfigName: "cert", ContainerPath: "/etc/ssl/cert.pem", Uid: "1000", Gid: "1000"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateConfigsAndMounts(tt.configs, tt.mounts)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
})
}
}
+10 -4
View File
@@ -7,7 +7,8 @@ import (
"strings"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units"
)
@@ -23,7 +24,7 @@ const (
)
type Container struct {
types.ContainerJSON
container.InspectResponse
// created caches the parsed creation time by CreatedTime.
created time.Time
}
@@ -52,7 +53,7 @@ func (c *Container) Healthy() bool {
return true
}
return c.State.Health.Status == types.Healthy
return c.State.Health.Status == container.Healthy
}
// HumanState returns a human-readable description of the container's state. Based on the Docker implementation:
@@ -78,7 +79,7 @@ func (c *Container) HumanState() (string, error) {
if c.State.Health != nil {
status := c.State.Health.Status
if status == types.Starting {
if status == container.Starting {
status = "health: " + status
}
@@ -144,6 +145,11 @@ type ServiceContainer struct {
ServiceSpec ServiceSpec
}
// ShortID returns the truncated ID of the container (12 characters).
func (c *ServiceContainer) ShortID() string {
return stringid.TruncateID(c.ID)
}
// ServiceID returns the ID of the service this container belongs to.
func (c *ServiceContainer) ServiceID() string {
return c.Config.Labels[LabelServiceID]

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