Compare commits

...
40 Commits
Author SHA1 Message Date
Pasha Sviderski aef4b2213f docs: rewrite Rolling deployments doc from Deployment strategies 2026-03-01 19:51:47 +10:00
Pasha Sviderski 44013da332 chore: format AGENTS, instruct to not use em dashes 2026-03-01 19:38:23 +10:00
Pasha Sviderski b48ebe2acc chore: UNCLOUD_HEALTH_MONITOR_PERIOD accepts duration (10s, 500ms, 0) 2026-03-01 08:08:57 +10:00
Pasha Sviderski eaa4455320 docs: generate CLI reference docs 2026-02-28 16:29:05 +10:00
Pasha Sviderski ce3cb8a34f feat: add --skip-health flag to bypass health monitoring during container deployment 2026-02-28 16:26:48 +10:00
Pasha Sviderski e6705599b3 chore: rename env var UNCLOUD_DEFAULT_HEALTH_MONITOR_PERIOD_MS -> UNCLOUD_HEALTH_MONITOR_PERIOD_MS 2026-02-28 16:03:36 +10:00
Pasha Sviderski 7b88040c73 fix: rolling container replacement: if new container fails, rollback and run old container only if it was running 2026-02-28 14:29:48 +10:00
Pasha Sviderski 7a9eac593f feat: implement container health monitoring and rollback during rolling deployment (closes #24) 2026-02-27 21:36:40 +10:00
Pasha Sviderski 17c1bb5c21 feat: add WaitContainerHealthy client method and health check utilities 2026-02-27 21:28:11 +10:00
Pasha Sviderski a5a9ef5444 chore: init container healthcheck const 2026-02-26 19:39:49 +10:00
Pasha Sviderski 2940965e31 fix: recreate container on ulimit changes, add test 2026-02-25 16:38:44 +10:00
Pasha Sviderski 185cf19a0c chore: minor full-spec test change 2026-02-25 16:03:28 +10:00
Pasha Sviderski 4443fa9e0e chore: update full-spec test to include latest implemented Compose attributes 2026-02-25 16:01:31 +10:00
Pasha Sviderski 3056e2af11 feat: add support for healthcheck in Compose 2026-02-25 14:54:24 +10:00
Pasha Sviderski 7bfaf31138 fix: image tag template formatting in docs 2026-02-24 16:42:55 +10:00
Pasha Sviderski f8d1945544 chore: add TODO for ReplaceContainerOperation 2026-02-24 15:30:23 +10:00
Pasha Sviderski 93d7f29d31 chore: remove TODO about encapsulating Client in deploy operations 2026-02-24 13:17:59 +10:00
Pasha Sviderski 18ebd29032 chore: split deploy operations into separate files in operation package 2026-02-24 11:42:47 +10:00
Pasha Sviderski fe56d6d708 chore: always run proxy to virtualised Docker on macOS for 'image push' except OrbStack 2026-02-23 20:06:23 +10:00
14056cc584 fix: format IPv6 addr correclty for HTTP URL (#254)
* fix: format IPv6 addr correclty for HTTP URL

* test: cover changes

* test: refactor

---------

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

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

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

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

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

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

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

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

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

* test: restore global with missing volume fails test

* refactor: simplify volume scheduler by removing redundant check

Move isVolumeSharedBetweenGlobalAndReplicated check earlier to fail
fast, then use isVolumeForGlobalService instead of the now-redundant
isVolumeOnlyForGlobalServices function.
2026-02-02 12:30:57 +01:00
Pasha Sviderski a4a8e70f9c fix: sshcli tests 2026-01-28 19:00:53 +10:00
Pasha Sviderski 1d4dbd96ce chore: minor sshcli refactgor 2026-01-28 18:17:55 +10:00
Pasha Sviderski 476a2d57ca fix: reset already initialised machine on 'uc machine init' when using ssh+cli connection 2026-01-28 18:08:57 +10:00
Pasha Sviderski ce46a67d6d fix: ssh args for machine init/add when using ssh+cli 2026-01-28 17:44:54 +10:00
Pasha Sviderski 87e49dfb62 chore: label temporary socat proxy containers created by 'uc image push' with uncloud.managed 2026-01-28 17:40:15 +10:00
Pasha Sviderski 808bda67d4 chore: remove unused constants 2026-01-28 17:28:35 +10:00
Pasha Sviderski c91a964513 chore: make ssh+cli connections reuse one SSH connection via control socket. Fix image push 2026-01-28 16:49:08 +10:00
Pasha Sviderski e0a63a3f49 chore: typo 2026-01-27 11:32:18 +10:00
Pasha Sviderski 0b2e1bb7a2 chore: update newsletter form to substack 2026-01-26 21:10:40 +10:00
Pasha Sviderski 7c4f739d07 chore: specify machines to deploy website to 2026-01-26 18:03:46 +10:00
NickandGitHub 3bd9400051 feat: add support for ulimits in compose and container creation (#221) (#244)
* feat: add support for ulimits in compose and container creation ([#221](https://github.com/ipaddicting/uncloud/issues/221))
* refactor: move Ulimits to ContainerResources and use map, also removed GEMINI.md.
2026-01-20 16:17:54 +10:00
54 changed files with 3519 additions and 719 deletions
+1
View File
@@ -39,3 +39,4 @@ node_modules/
# VS Code # VS Code
.vscode .vscode
.devcontainer/
+38 -31
View File
@@ -1,10 +1,14 @@
# AI.md - Uncloud Project Guide # AGENTS.md - Uncloud Project Guide
This document provides comprehensive information about the Uncloud project for AI assistants to understand the codebase, architecture, and development practices. This document provides comprehensive information about the Uncloud project for AI assistants to understand the codebase,
architecture, and development practices.
## Project Overview ## Project Overview
**Uncloud** is a lightweight clustering and container orchestration tool that enables deployment and management of web applications across cloud VMs and bare metal servers. It creates a secure WireGuard mesh network between Docker hosts and provides automatic service discovery, load balancing, HTTPS ingress, and simple CLI commands for application management. **Uncloud** is a lightweight clustering and container orchestration tool that enables deployment and management of web
applications across cloud VMs and bare metal servers. It creates a secure WireGuard mesh network between Docker hosts
and provides automatic service discovery, load balancing, HTTPS ingress, and simple CLI commands for application
management.
### Key Characteristics ### Key Characteristics
@@ -68,29 +72,29 @@ This document provides comprehensive information about the Uncloud project for A
- **`cmd/`**: Contains main applications - **`cmd/`**: Contains main applications
- `uncloud/`: CLI tool with subcommands for machine, service, volume management - `uncloud/`: CLI tool with subcommands for machine, service, volume management
- `uncloudd/`: Daemon that runs on each machine - `uncloudd/`: Daemon that runs on each machine
- `ucind/`: Development cluster management for testing - `ucind/`: Development cluster management for testing
- **`internal/`**: Internal implementation packages - **`internal/`**: Internal implementation packages
- `cli/`: Command-line interface logic - `cli/`: Command-line interface logic
- `machine/`: Machine lifecycle and state management - `machine/`: Machine lifecycle and state management
- `daemon/`: Daemon implementation and gRPC services - `daemon/`: Daemon implementation and gRPC services
- `dns/`: Internal DNS server for service discovery - `dns/`: Internal DNS server for service discovery
- **`pkg/`**: Public API packages for external use - **`pkg/`**: Public API packages for external use
- `api/`: Core API types and definitions - `api/`: Core API types and definitions
- `client/`: Client libraries for interacting with Uncloud - `client/`: Client libraries for interacting with Uncloud
- **`experiment/`**: Experimental features and prototypes - **`experiment/`**: Experimental features and prototypes
- **`scripts/`**: Installation and utility scripts - **`scripts/`**: Installation and utility scripts
- **`test/`**: Test suites and test infrastructure - **`test/`**: Test suites and test infrastructure
- **`website/`**: Documentation website (Docusaurus) - **`website/`**: Documentation website (Docusaurus)
- `landing/`: Landing page - `landing/`: Landing page
- `docs/`: User documentation - `docs/`: User documentation
- **`misc/`**: Design documents and guides - **`misc/`**: Design documents and guides
@@ -100,25 +104,25 @@ This document provides comprehensive information about the Uncloud project for A
```go ```go
// Networking and orchestration // Networking and orchestration
github.com/docker/docker // Docker API client github.com/docker/docker // Docker API client
github.com/docker/compose/v2 // Docker Compose integration github.com/docker/compose/v2 // Docker Compose integration
golang.zx2c4.com/wireguard // WireGuard implementation golang.zx2c4.com/wireguard // WireGuard implementation
github.com/hashicorp/serf // Gossip protocol github.com/hashicorp/serf // Gossip protocol
// State management // State management
github.com/ipfs/go-ds-crdt // CRDT distributed storage github.com/ipfs/go-ds-crdt // CRDT distributed storage
github.com/dgraph-io/badger/v3 // Embedded database github.com/dgraph-io/badger/v3 // Embedded database
// Web proxy // Web proxy
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
// CLI and UX // CLI and UX
github.com/spf13/cobra // CLI framework github.com/spf13/cobra // CLI framework
github.com/charmbracelet/huh // Interactive forms github.com/charmbracelet/huh // Interactive forms
// gRPC and networking // gRPC and networking
google.golang.org/grpc // gRPC framework google.golang.org/grpc // gRPC framework
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
``` ```
## Development Workflow ## Development Workflow
@@ -189,9 +193,9 @@ uc context use <name> # Switch context
### Testing ### Testing
- Test files and locations - Test files and locations
- Unit tests alongside source files (`*_test.go`) - Unit tests alongside source files (`*_test.go`)
- Integration tests in `test/e2e/` - Integration tests in `test/e2e/`
- Test fixtures in `test/fixtures/` - Test fixtures in `test/fixtures/`
- Use table driven tests whenever possible - Use table driven tests whenever possible
### Dependencies ### Dependencies
@@ -225,8 +229,8 @@ uc context use <name> # Switch context
### Logs and Monitoring ### Logs and Monitoring
- Systemd services (getting logs via `journalctl -u SERVICE_NAME`) - Systemd services (getting logs via `journalctl -u SERVICE_NAME`)
- `uncloud` -- Uncloud daemon - `uncloud` -- Uncloud daemon
- `uncloud-corrosion` -- Corrosion process - `uncloud-corrosion` -- Corrosion process
- Machine daemon logs - Machine daemon logs
- Container logs via Docker - Container logs via Docker
@@ -247,7 +251,8 @@ uc context use <name> # Switch context
- `Makefile`: Build and development tasks - `Makefile`: Build and development tasks
- `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing) - `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing)
This document should help AI assistants understand the project structure, make informed suggestions, and contribute effectively to the Uncloud codebase. This document should help AI assistants understand the project structure, make informed suggestions, and contribute
effectively to the Uncloud codebase.
## Documentation ## Documentation
@@ -255,4 +260,6 @@ Instructions when generating documentation:
- Use conversational language write as if you were speaking to a friend. - Use conversational language write as if you were speaking to a friend.
- Keep sentences simple, optimize for clarity and understanding. - Keep sentences simple, optimize for clarity and understanding.
- Place the subject before the action whenever possible. Example: prefer “The function loads data” over “Data is loaded by the function.” - Do not use em dashes (—) or semicolons (;) in sentences. Instead, break complex sentences into simpler ones.
- Place the subject before the action whenever possible. Example: prefer "The function loads data" over "Data is loaded
by the function."
+1 -1
View File
@@ -1 +1 @@
./AI.md ./AGENTS.md
+16 -10
View File
@@ -14,18 +14,20 @@ import (
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/psviderski/uncloud/pkg/client/compose" "github.com/psviderski/uncloud/pkg/client/compose"
"github.com/psviderski/uncloud/pkg/client/deploy" "github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type deployOptions struct { type deployOptions struct {
cli.BuildServicesOptions cli.BuildServicesOptions
files []string files []string
profiles []string profiles []string
services []string services []string
noBuild bool noBuild bool
recreate bool recreate bool
yes bool skipHealth bool
yes bool
} }
// NewDeployCommand creates a new command to deploy services from a Compose file. // NewDeployCommand creates a new command to deploy services from a Compose file.
@@ -60,6 +62,10 @@ func NewDeployCommand() *cobra.Command {
"One or more Compose profiles to enable.") "One or more Compose profiles to enable.")
cmd.Flags().BoolVar(&opts.recreate, "recreate", false, cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
"Recreate containers even if their configuration and image haven't changed.") "Recreate containers even if their configuration and image haven't changed.")
cmd.Flags().BoolVar(&opts.skipHealth, "skip-health", false,
"Skip the monitoring period and health checks after starting new containers. Useful for faster emergency "+
"deployments.\n"+
"Warning: This may cause downtime if new containers fail to start properly.")
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+ "Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]") "e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
@@ -148,9 +154,9 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
fmt.Println() fmt.Println()
} }
var strategy deploy.Strategy strategy := &deploy.RollingStrategy{
if opts.recreate { ForceRecreate: opts.recreate,
strategy = &deploy.RollingStrategy{ForceRecreate: true} SkipHealthMonitor: opts.skipHealth,
} }
composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy) composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy)
if err != nil { if err != nil {
@@ -198,7 +204,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
}, uncli.ProgressOut(), "Deploying services") }, uncli.ProgressOut(), "Deploying services")
} }
func printPlan(ctx context.Context, cli *client.Client, plan deploy.SequenceOperation) error { func printPlan(ctx context.Context, cli *client.Client, plan operation.SequenceOperation) error {
for _, op := range plan.Operations { for _, op := range plan.Operations {
svcPlan, ok := op.(*deploy.Plan) svcPlan, ok := op.(*deploy.Plan)
if !ok { if !ok {
+1 -1
View File
@@ -66,6 +66,7 @@ require (
google.golang.org/grpc v1.74.2 google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.9 google.golang.org/protobuf v1.36.9
modernc.org/sqlite v1.36.3 modernc.org/sqlite v1.36.3
tags.cncf.io/container-device-interface v1.0.1
) )
require ( require (
@@ -415,5 +416,4 @@ require (
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect
tags.cncf.io/container-device-interface v1.0.1 // indirect
) )
+13 -17
View File
@@ -11,11 +11,6 @@ import (
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
) )
const (
DefaultSSHUser = "root"
DefaultSSHPort = 22
)
type MachineConnection struct { type MachineConnection struct {
SSH SSHDestination `yaml:"ssh,omitempty"` SSH SSHDestination `yaml:"ssh,omitempty"`
SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"` SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"`
@@ -30,7 +25,7 @@ type MachineConnection struct {
MachineID string `yaml:"machine_id,omitempty"` MachineID string `yaml:"machine_id,omitempty"`
} }
func (c MachineConnection) String() string { func (c *MachineConnection) String() string {
if c.SSH != "" { if c.SSH != "" {
return "ssh://" + string(c.SSH) return "ssh://" + string(c.SSH)
} else if c.SSHCLI != "" { } else if c.SSHCLI != "" {
@@ -69,36 +64,37 @@ func (c *MachineConnection) Validate() error {
} }
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port". // SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
// The default user "root" and port 22 can be omitted. // Empty user or port components are omitted.
type SSHDestination string type SSHDestination string
// NewSSHDestination constructs an SSHDestination from user, host, and port components.
// If user is empty, it is omitted.
// If port is 0, it is omitted.
func NewSSHDestination(user, host string, port int) SSHDestination { func NewSSHDestination(user, host string, port int) SSHDestination {
dst := host dst := host
if port != 0 && port != DefaultSSHPort { if port != 0 {
dst = net.JoinHostPort(host, strconv.Itoa(port)) dst = net.JoinHostPort(host, strconv.Itoa(port))
} }
if user == "" { if user != "" {
user = DefaultSSHUser dst = fmt.Sprintf("%s@%s", user, dst)
} }
dst = user + "@" + dst
return SSHDestination(dst) return SSHDestination(dst)
} }
// Parse parses the SSH destination string into user, host, and port components.
// If user is not specified, it returns an empty string.
// If port is not specified, it returns 0.
func (d SSHDestination) Parse() (user string, host string, port int, err error) { func (d SSHDestination) Parse() (user string, host string, port int, err error) {
host = string(d) host = string(d)
if strings.Contains(host, "@") { if strings.Contains(host, "@") {
user, host, _ = strings.Cut(host, "@") user, host, _ = strings.Cut(host, "@")
} }
if user == "" {
user = DefaultSSHUser
}
h, p, sErr := net.SplitHostPort(host) h, p, sErr := net.SplitHostPort(host)
if sErr == nil { if sErr == nil {
host = h host = h
port, err = strconv.Atoi(p) port, err = strconv.Atoi(p)
} }
if port == 0 {
port = DefaultSSHPort
}
return return
} }
+53
View File
@@ -33,6 +33,7 @@ import (
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy" "github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/docker/go-units"
"github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote"
@@ -566,6 +567,22 @@ func (s *Server) CreateServiceContainer(
if spec.Mode == "" { if spec.Mode == "" {
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
} }
if hc := spec.Container.Healthcheck; hc != nil {
if hc.Disable {
config.Healthcheck = &container.HealthConfig{
Test: []string{"NONE"},
}
} else {
config.Healthcheck = &container.HealthConfig{
Test: hc.Test,
Interval: hc.Interval,
Timeout: hc.Timeout,
StartPeriod: hc.StartPeriod,
StartInterval: hc.StartInterval,
Retries: int(hc.Retries),
}
}
}
// TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB. // TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB.
var err error var err error
@@ -616,7 +633,9 @@ func (s *Server) CreateServiceContainer(
NanoCPUs: spec.Container.Resources.CPU, NanoCPUs: spec.Container.Resources.CPU,
Memory: spec.Container.Resources.Memory, Memory: spec.Container.Resources.Memory,
MemoryReservation: spec.Container.Resources.MemoryReservation, MemoryReservation: spec.Container.Resources.MemoryReservation,
Devices: toDockerDevices(spec.Container.Resources.Devices),
DeviceRequests: spec.Container.Resources.DeviceReservations, DeviceRequests: spec.Container.Resources.DeviceReservations,
Ulimits: toDockerUlimits(spec.Container.Resources.Ulimits),
}, },
// Restart service containers if they exit or a machine restarts unless they are explicitly stopped. // Restart service containers if they exit or a machine restarts unless they are explicitly stopped.
// For one-off containers and batch jobs we plan to use a different service type/mode. // For one-off containers and batch jobs we plan to use a different service type/mode.
@@ -877,6 +896,40 @@ func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
return dockerOpts return dockerOpts
} }
func toDockerUlimits(ulimits map[string]api.Ulimit) []*units.Ulimit {
if len(ulimits) == 0 {
return nil
}
dockerUlimits := make([]*units.Ulimit, 0, len(ulimits))
for name, u := range ulimits {
dockerUlimits = append(dockerUlimits, &units.Ulimit{
Name: name,
Soft: u.Soft,
Hard: u.Hard,
})
}
return dockerUlimits
}
func toDockerDevices(devices []api.DeviceMapping) []container.DeviceMapping {
if len(devices) == 0 {
return nil
}
dockerDevices := make([]container.DeviceMapping, 0, len(devices))
for _, d := range devices {
dockerDevices = append(dockerDevices, container.DeviceMapping{
PathOnHost: d.HostPath,
PathInContainer: d.ContainerPath,
CgroupPermissions: d.CgroupPermissions,
})
}
return dockerDevices
}
// verifyDockerVolumesExist checks if the Docker named volumes referenced in the mounts exist on the machine. // verifyDockerVolumesExist checks if the Docker named volumes referenced in the mounts exist on the machine.
func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mount) error { func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mount) error {
for _, m := range mounts { for _, m := range mounts {
+11
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
osuser "os/user"
"strconv" "strconv"
"time" "time"
@@ -13,7 +14,17 @@ import (
) )
func Connect(user, host string, port int, sshKeyPath string) (*ssh.Client, error) { func Connect(user, host string, port int, sshKeyPath string) (*ssh.Client, error) {
// Use the current OS user if no user is specified to be make it consistent with ssh CLI behavior.
if user == "" {
if u, err := osuser.Current(); err == nil {
user = u.Username
}
}
if port == 0 {
port = 22
}
addr := net.JoinHostPort(host, strconv.Itoa(port)) addr := net.JoinHostPort(host, strconv.Itoa(port))
// Try to connect using SSH agent only. // Try to connect using SSH agent only.
agentAuth, agentClose, agentErr := sshAgentAuth() agentAuth, agentClose, agentErr := sshAgentAuth()
if agentErr == nil { if agentErr == nil {
+7 -2
View File
@@ -30,7 +30,7 @@ func NewSSHCLIRemote(user, host string, port int, keyPath string) *SSHCLIRemote
func (r *SSHCLIRemote) buildSSHArgs() []string { func (r *SSHCLIRemote) buildSSHArgs() []string {
args := []string{"-o", "ConnectTimeout=5"} args := []string{"-o", "ConnectTimeout=5"}
if r.port != 0 && r.port != 22 { if r.port != 0 {
args = append(args, "-p", strconv.Itoa(r.port)) args = append(args, "-p", strconv.Itoa(r.port))
} }
@@ -38,7 +38,12 @@ func (r *SSHCLIRemote) buildSSHArgs() []string {
args = append(args, "-i", r.keyPath) args = append(args, "-i", r.keyPath)
} }
args = append(args, r.user+"@"+r.host) dst := r.host
if r.user != "" {
dst = fmt.Sprintf("%s@%s", r.user, dst)
}
args = append(args, dst)
return args return args
} }
+27 -15
View File
@@ -18,29 +18,49 @@ func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
expected []string expected []string
}{ }{
{ {
name: "default port, no key", name: "host only",
host: "example.com",
expected: []string{"-o", "ConnectTimeout=5", "example.com"},
},
{
name: "with user",
user: "root", user: "root",
host: "example.com", host: "example.com",
port: 22,
keyPath: "",
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"}, expected: []string{"-o", "ConnectTimeout=5", "root@example.com"},
}, },
{ {
name: "custom port", name: "with port",
host: "example.com",
port: 2222,
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "example.com"},
},
{
name: "with key",
host: "example.com",
keyPath: "/path/to/key",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "example.com"},
},
{
name: "user and port",
user: "ubuntu", user: "ubuntu",
host: "192.168.1.10", host: "192.168.1.10",
port: 2222, port: 2222,
keyPath: "",
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "ubuntu@192.168.1.10"}, expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "ubuntu@192.168.1.10"},
}, },
{ {
name: "with key path", name: "user and key",
user: "root", user: "root",
host: "example.com", host: "example.com",
port: 22,
keyPath: "/path/to/key", keyPath: "/path/to/key",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com"}, expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com"},
}, },
{
name: "port and key",
host: "example.com",
port: 22,
keyPath: "~/.ssh/id_rsa",
expected: []string{"-o", "ConnectTimeout=5", "-p", "22", "-i", "~/.ssh/id_rsa", "example.com"},
},
{ {
name: "all options", name: "all options",
user: "admin", user: "admin",
@@ -49,14 +69,6 @@ func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
keyPath: "~/.ssh/id_rsa", keyPath: "~/.ssh/id_rsa",
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "~/.ssh/id_rsa", "admin@server.local"}, 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 { for _, tt := range tests {
+10 -2
View File
@@ -21,11 +21,14 @@ type ContainerClient interface {
CreateContainer( CreateContainer(
ctx context.Context, serviceID string, spec ServiceSpec, machineID string, ctx context.Context, serviceID string, spec ServiceSpec, machineID string,
) (container.CreateResponse, error) ) (container.CreateResponse, error)
ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error)
InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error) InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error)
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error) RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
WaitContainerHealthy(
ctx context.Context, serviceNameOrID, containerNameOrID string, opts WaitContainerHealthyOptions,
) error
} }
type DNSClient interface { type DNSClient interface {
@@ -57,3 +60,8 @@ type VolumeClient interface {
ListVolumes(ctx context.Context, filter *VolumeFilter) ([]MachineVolume, error) ListVolumes(ctx context.Context, filter *VolumeFilter) ([]MachineVolume, error)
RemoveVolume(ctx context.Context, machineNameOrID, volumeName string, force bool) error RemoveVolume(ctx context.Context, machineNameOrID, volumeName string, force bool) error
} }
// AsPtr returns a pointer to the given value. Useful for optional fields in API structs.
func AsPtr[T any](v T) *T {
return &v
}
+73
View File
@@ -3,7 +3,9 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/netip" "net/netip"
"os"
"strings" "strings"
"time" "time"
@@ -41,6 +43,19 @@ func (c *Container) CreatedTime() time.Time {
return c.created return c.created
} }
// HasHealthcheck returns true if the container has a health check configured.
func (c *Container) HasHealthcheck() bool {
hc := c.Config.Healthcheck
if hc == nil {
return false
}
if len(hc.Test) > 0 && hc.Test[0] == "NONE" {
return false
}
return true
}
// Healthy determines if the container is running and healthy. // Healthy determines if the container is running and healthy.
// A running container with no health check configured is considered healthy. // A running container with no health check configured is considered healthy.
func (c *Container) Healthy() bool { func (c *Container) Healthy() bool {
@@ -244,3 +259,61 @@ func (c *ServiceContainer) UnmarshalJSON(data []byte) error {
return nil return nil
} }
// DefaultHealthMonitorPeriod is the default duration (5 seconds) to wait before checking that the container is still
// running and not restarting. Can be overridden with the UNCLOUD_HEALTH_MONITOR_PERIOD environment variable
// (e.g. "10s" or "0").
var DefaultHealthMonitorPeriod = defaultHealthMonitorPeriod()
func defaultHealthMonitorPeriod() time.Duration {
if v, ok := os.LookupEnv("UNCLOUD_HEALTH_MONITOR_PERIOD"); ok {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return 5 * time.Second
}
// WaitContainerHealthyOptions configures the behaviour of WaitContainerHealthy.
type WaitContainerHealthyOptions struct {
// MonitorPeriod is how long to wait before checking that the container is still running and not restarting.
// Containers with a health check that become healthy before the period ends succeed early.
// nil means use the default DefaultHealthMonitorPeriod.
// Zero skips the monitoring and checks the container's health immediately after starting.
MonitorPeriod *time.Duration
}
// ExecOptions contains configuration for executing a command in a container.
type ExecOptions struct {
// Command is the command to run in the container.
Command []string
// AttachStdin attaches the stdin stream to the exec session.
AttachStdin bool
// AttachStdout attaches the stdout stream to the exec session.
AttachStdout bool
// AttachStderr attaches the stderr stream to the exec session.
AttachStderr bool
// Tty allocates a pseudo-TTY for the exec session.
Tty bool
// Detach runs the command in the background without attaching to streams.
Detach bool
//// Not yet implemented fields
// User specifies the user to run the command as.
User string
// Privileged runs the command in privileged mode.
Privileged bool
// WorkingDir sets the working directory for the command.
WorkingDir string
// Env sets environment variables for the command.
Env []string
// Client-side only fields (not serialized, not sent to server)
// Stdin is the input stream. Defaults to os.Stdin if nil.
Stdin io.Reader `json:"-"`
// Stdout is the output stream. Defaults to os.Stdout if nil.
Stdout io.Writer `json:"-"`
// Stderr is the error stream. Defaults to os.Stderr if nil.
Stderr io.Writer `json:"-"`
}
-37
View File
@@ -1,37 +0,0 @@
package api
import "io"
// ExecOptions contains configuration for executing a command in a container.
type ExecOptions struct {
// Command is the command to run in the container.
Command []string
// AttachStdin attaches the stdin stream to the exec session.
AttachStdin bool
// AttachStdout attaches the stdout stream to the exec session.
AttachStdout bool
// AttachStderr attaches the stderr stream to the exec session.
AttachStderr bool
// Tty allocates a pseudo-TTY for the exec session.
Tty bool
// Detach runs the command in the background without attaching to streams.
Detach bool
//// Not yet implemented fields
// User specifies the user to run the command as.
User string
// Privileged runs the command in privileged mode.
Privileged bool
// WorkingDir sets the working directory for the command.
WorkingDir string
// Env sets environment variables for the command.
Env []string
// Client-side only fields (not serialized, not sent to server)
// Stdin is the input stream. Defaults to os.Stdin if nil.
Stdin io.Reader `json:"-"`
// Stdout is the output stream. Defaults to os.Stdout if nil.
Stdout io.Writer `json:"-"`
// Stderr is the error stream. Defaults to os.Stderr if nil.
Stderr io.Writer `json:"-"`
}
+20 -1
View File
@@ -17,6 +17,25 @@ type ContainerResources struct {
// MemoryReservation is the minimum amount of memory (in bytes) the container needs to run efficiently. // MemoryReservation is the minimum amount of memory (in bytes) the container needs to run efficiently.
// TODO: implement a placement constraint that checks available memory on machines. // TODO: implement a placement constraint that checks available memory on machines.
MemoryReservation int64 MemoryReservation int64
// Device reservations/requests for access to things like GPUs // Devices provides direct access to host devices.
Devices []DeviceMapping
// DeviceReservations requests for access to things like GPUs.
DeviceReservations []container.DeviceRequest DeviceReservations []container.DeviceRequest
// Ulimits defines the resource limits for the container.
Ulimits map[string]Ulimit
}
// DeviceMapping represents a device mapping between host and container.
type DeviceMapping struct {
// HostPath is the path to the device on the host.
HostPath string
// ContainerPath is the path to the device in the container.
ContainerPath string
// CgroupPermissions is the cgroup permissions for the device (e.g., "rwm").
CgroupPermissions string
}
type Ulimit struct {
Soft int64
Hard int64
} }
+69 -8
View File
@@ -7,6 +7,7 @@ import (
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"time"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/distribution/reference" "github.com/distribution/reference"
@@ -19,6 +20,13 @@ const (
ServiceModeReplicated = "replicated" ServiceModeReplicated = "replicated"
ServiceModeGlobal = "global" ServiceModeGlobal = "global"
// UpdateOrderStartFirst starts the new container before stopping the old one.
// This minimizes downtime but briefly runs both containers.
UpdateOrderStartFirst = "start-first"
// UpdateOrderStopFirst stops the old container before starting the new one.
// This prevents data corruption for stateful services but causes brief downtime.
UpdateOrderStopFirst = "stop-first"
// PullPolicyAlways means the image is always pulled from the registry. // PullPolicyAlways means the image is always pulled from the registry.
PullPolicyAlways = "always" PullPolicyAlways = "always"
// PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where // PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where
@@ -59,6 +67,8 @@ type ServiceSpec struct {
Ports []PortSpec Ports []PortSpec
// Replicas is the number of containers to run for the service. Only valid for a replicated service. // Replicas is the number of containers to run for the service. Only valid for a replicated service.
Replicas uint `json:",omitempty"` Replicas uint `json:",omitempty"`
// UpdateConfig configures how the service is updated during a deployment.
UpdateConfig UpdateConfig `json:",omitempty"`
// Volumes is list of data volumes that can be mounted into the container. // Volumes is list of data volumes that can be mounted into the container.
Volumes []VolumeSpec Volumes []VolumeSpec
// Configs is list of configuration objects that can be mounted into the container. // Configs is list of configuration objects that can be mounted into the container.
@@ -234,8 +244,11 @@ type ContainerSpec struct {
// Entrypoint overrides the default ENTRYPOINT of the image. // Entrypoint overrides the default ENTRYPOINT of the image.
Entrypoint []string Entrypoint []string
// Env defines the environment variables to set inside the container. // Env defines the environment variables to set inside the container.
Env EnvVars Env EnvVars
Image string // Healthcheck defines the health check configuration for the container or overrides the health check options
// defined in the image. If nil, the image's default health check is used.
Healthcheck *HealthcheckSpec `json:",omitempty"`
Image string
// Run a custom init inside the container. If nil, use the daemon's configured settings. // Run a custom init inside the container. If nil, use the daemon's configured settings.
Init *bool Init *bool
// LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default. // LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default.
@@ -330,6 +343,17 @@ func (s *ContainerSpec) Clone() ContainerSpec {
spec.Entrypoint = make([]string, len(s.Entrypoint)) spec.Entrypoint = make([]string, len(s.Entrypoint))
copy(spec.Entrypoint, s.Entrypoint) copy(spec.Entrypoint, s.Entrypoint)
} }
if s.Env != nil {
spec.Env = make(EnvVars, len(s.Env))
for k, v := range s.Env {
spec.Env[k] = v
}
}
if s.Healthcheck != nil {
hc := *s.Healthcheck
hc.Test = slices.Clone(s.Healthcheck.Test)
spec.Healthcheck = &hc
}
if s.LogDriver != nil { if s.LogDriver != nil {
logDriver := *s.LogDriver logDriver := *s.LogDriver
if s.LogDriver.Options != nil { if s.LogDriver.Options != nil {
@@ -337,12 +361,6 @@ func (s *ContainerSpec) Clone() ContainerSpec {
} }
spec.LogDriver = &logDriver spec.LogDriver = &logDriver
} }
if s.Env != nil {
spec.Env = make(EnvVars, len(s.Env))
for k, v := range s.Env {
spec.Env[k] = v
}
}
if s.Volumes != nil { if s.Volumes != nil {
spec.Volumes = make([]string, len(s.Volumes)) spec.Volumes = make([]string, len(s.Volumes))
copy(spec.Volumes, s.Volumes) copy(spec.Volumes, s.Volumes)
@@ -363,6 +381,16 @@ func (s *ContainerSpec) Clone() ContainerSpec {
spec.Sysctls[k] = v spec.Sysctls[k] = v
} }
} }
if s.Resources.Ulimits != nil {
spec.Resources.Ulimits = maps.Clone(s.Resources.Ulimits)
}
if s.Resources.Devices != nil {
spec.Resources.Devices = slices.Clone(s.Resources.Devices)
}
if s.Resources.DeviceReservations != nil {
spec.Resources.DeviceReservations = slices.Clone(s.Resources.DeviceReservations)
}
return spec return spec
} }
@@ -380,6 +408,26 @@ func (e EnvVars) ToSlice() []string {
return env return env
} }
// HealthcheckSpec defines the health check configuration for a container.
type HealthcheckSpec struct {
// Test is the command used to check health.
// Formats: ["CMD", args...], ["CMD-SHELL", "command"], or ["NONE"] to disable.
Test []string `json:",omitempty"`
// Interval is the time between health checks.
// Zero means to inherit the value from the image or use the Docker default (30s) if not defined in the image.
Interval time.Duration `json:",omitempty"`
// Timeout is how long to wait before considering the checck to have hung.
Timeout time.Duration `json:",omitempty"`
// StartPeriod is the initialisation time for a container before the retries start to count down.
StartPeriod time.Duration `json:",omitempty"`
// StartInterval is the time between health checks during the start period.
StartInterval time.Duration `json:",omitempty"`
// Retries is the number of consecutive failures needed to consider a container unhealthy.
Retries uint `json:",omitempty"`
// Disable disables the health check defined in the image. true is equivalent to setting Test to ["NONE"].
Disable bool `json:",omitempty"`
}
type LogDriver struct { type LogDriver struct {
// Name of the logging driver to use. // Name of the logging driver to use.
Name string Name string
@@ -387,6 +435,19 @@ type LogDriver struct {
Options map[string]string Options map[string]string
} }
// UpdateConfig configures how a service is updated during a deployment.
type UpdateConfig struct {
// Order specifies the order of operations during an update.
// Valid values are "start-first" (default for stateless services) and "stop-first" (default for services with
// volumes). Empty value means the strategy will determine the order based on service characteristics.
Order string `json:",omitempty"`
// MonitorPeriod is how long to wait after starting a container before checking that it's still running
// and not restarting. Containers with a health check that become healthy before the period ends succeed early.
// nil means use the default api.DefaultHealthMonitorPeriod.
// Zero skips the monitoring and checks the container's health immediately after starting.
MonitorPeriod *time.Duration `json:",omitempty"`
}
type RunServiceResponse struct { type RunServiceResponse struct {
ID string ID string
Name string Name string
+13
View File
@@ -4,6 +4,7 @@ import (
"os" "os"
"testing" "testing"
"github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -231,6 +232,12 @@ func TestContainerSpec_Clone(t *testing.T) {
CPU: 1234, CPU: 1234,
Memory: 2345, Memory: 2345,
MemoryReservation: 3456, MemoryReservation: 3456,
Devices: []DeviceMapping{
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
},
DeviceReservations: []container.DeviceRequest{
{Count: 1, Capabilities: [][]string{{"gpu"}}, Driver: "nvidia"},
},
}, },
Sysctls: map[string]string{ Sysctls: map[string]string{
"net.ipv4.ip_forward": "1", "net.ipv4.ip_forward": "1",
@@ -263,6 +270,9 @@ func TestContainerSpec_Clone(t *testing.T) {
original.ConfigMounts[0].ContainerPath = stringModified original.ConfigMounts[0].ContainerPath = stringModified
*original.ConfigMounts[0].Mode = 0o755 // Modify the Mode pointer value *original.ConfigMounts[0].Mode = 0o755 // Modify the Mode pointer value
original.Sysctls["net.ipv4.ip_forward"] = stringModified original.Sysctls["net.ipv4.ip_forward"] = stringModified
original.Resources.Devices[0].HostPath = stringModified
original.Resources.DeviceReservations[0].Count = 2
original.Resources.DeviceReservations[0].Driver = stringModified
assert.False(t, original.Equals(cloned)) assert.False(t, original.Equals(cloned))
// Assert cloned values are unchanged // Assert cloned values are unchanged
@@ -283,6 +293,9 @@ func TestContainerSpec_Clone(t *testing.T) {
assert.Equal(t, int64(1234), cloned.Resources.CPU) assert.Equal(t, int64(1234), cloned.Resources.CPU)
assert.Equal(t, int64(2345), cloned.Resources.Memory) assert.Equal(t, int64(2345), cloned.Resources.Memory)
assert.Equal(t, int64(3456), cloned.Resources.MemoryReservation) assert.Equal(t, int64(3456), cloned.Resources.MemoryReservation)
assert.Equal(t, "/dev/sda", cloned.Resources.Devices[0].HostPath)
assert.Equal(t, 1, cloned.Resources.DeviceReservations[0].Count)
assert.Equal(t, "nvidia", cloned.Resources.DeviceReservations[0].Driver)
assert.Equal(t, "1000:1000", cloned.User) assert.Equal(t, "1000:1000", cloned.User)
assert.Equal(t, "/data", cloned.Volumes[0]) assert.Equal(t, "/data", cloned.Volumes[0])
assert.Equal(t, "/data", cloned.VolumeMounts[0].ContainerPath) assert.Equal(t, "/data", cloned.VolumeMounts[0].ContainerPath)
+7 -6
View File
@@ -13,6 +13,7 @@ import (
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy" "github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
) )
@@ -27,7 +28,7 @@ type Deployment struct {
SpecResolver *deploy.ServiceSpecResolver SpecResolver *deploy.ServiceSpecResolver
Strategy deploy.Strategy Strategy deploy.Strategy
state *scheduler.ClusterState state *scheduler.ClusterState
plan *deploy.SequenceOperation plan *operation.SequenceOperation
} }
func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) { func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) {
@@ -58,11 +59,11 @@ func NewDeploymentWithStrategy(ctx context.Context, cli Client, project *types.P
}, nil }, nil
} }
func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error) { func (d *Deployment) Plan(ctx context.Context) (operation.SequenceOperation, error) {
if d.plan != nil { if d.plan != nil {
return *d.plan, nil return *d.plan, nil
} }
plan := deploy.SequenceOperation{} plan := operation.SequenceOperation{}
// Generate service specs for all services in the project. // Generate service specs for all services in the project.
var serviceSpecs []api.ServiceSpec var serviceSpecs []api.ServiceSpec
@@ -123,7 +124,7 @@ func (d *Deployment) ServiceSpec(name string) (api.ServiceSpec, error) {
} }
// PlanVolumes checks if the external volumes exist and plans the creation of missing volumes. // PlanVolumes checks if the external volumes exist and plans the creation of missing volumes.
func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.CreateVolumeOperation, error) { func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*operation.CreateVolumeOperation, error) {
if len(d.Project.Volumes) == 0 { if len(d.Project.Volumes) == 0 {
// No volumes to check or create. // No volumes to check or create.
return nil, nil return nil, nil
@@ -145,7 +146,7 @@ func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.Crea
} }
// Generate operations to create scheduled missing volumes. // Generate operations to create scheduled missing volumes.
var ops []*deploy.CreateVolumeOperation var ops []*operation.CreateVolumeOperation
for machineID, volumes := range scheduledVolumes { for machineID, volumes := range scheduledVolumes {
for _, v := range volumes { for _, v := range volumes {
machineName := machineID machineName := machineID
@@ -153,7 +154,7 @@ func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.Crea
machineName = m.Info.Name machineName = m.Info.Name
} }
ops = append(ops, &deploy.CreateVolumeOperation{ ops = append(ops, &operation.CreateVolumeOperation{
MachineID: machineID, MachineID: machineID,
MachineName: machineName, MachineName: machineName,
VolumeSpec: v, VolumeSpec: v,
+21
View File
@@ -6,13 +6,23 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
composecli "github.com/compose-spec/compose-go/v2/cli" composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/transform"
"github.com/compose-spec/compose-go/v2/tree"
"github.com/compose-spec/compose-go/v2/types" "github.com/compose-spec/compose-go/v2/types"
"github.com/psviderski/uncloud/pkg/api"
) )
var registerComposeOverrides sync.Once
// LoadProject loads a Compose project from the default locations or the given paths. // LoadProject loads a Compose project from the default locations or the given paths.
func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) { func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) {
registerComposeOverrides.Do(func() {
transform.RegisterDefaultValue("services.*.deploy.update_config", setUpdateConfigDefaults)
})
defaultOpts := []composecli.ProjectOptionsFn{ defaultOpts := []composecli.ProjectOptionsFn{
// First apply os.Environment, always wins. // First apply os.Environment, always wins.
composecli.WithOsEnv, composecli.WithOsEnv,
@@ -92,3 +102,14 @@ func removeProjectPrefixFromNames(project *types.Project) {
project.Volumes[name] = vol project.Volumes[name] = vol
} }
} }
// setUpdateConfigDefaults sets default values for deploy.update_config attributes when not specified in the compose file.
func setUpdateConfigDefaults(data any, _ tree.Path, _ bool) (any, error) {
switch v := data.(type) {
case map[string]any:
if _, ok := v["monitor"]; !ok {
v["monitor"] = api.DefaultHealthMonitorPeriod.String()
}
}
return data, nil
}
+108 -12
View File
@@ -5,12 +5,14 @@ import (
"maps" "maps"
"os" "os"
"slices" "slices"
"time"
"github.com/compose-spec/compose-go/v2/types" "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/opencontainers/go-digest" "github.com/opencontainers/go-digest"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
cdi "tags.cncf.io/container-device-interface/pkg/parser"
) )
func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.ServiceSpec, error) { func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.ServiceSpec, error) {
@@ -43,18 +45,19 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
spec := api.ServiceSpec{ spec := api.ServiceSpec{
Container: api.ContainerSpec{ Container: api.ContainerSpec{
CapAdd: service.CapAdd, CapAdd: service.CapAdd,
CapDrop: service.CapDrop, CapDrop: service.CapDrop,
Command: service.Command, Command: service.Command,
Entrypoint: service.Entrypoint, Entrypoint: service.Entrypoint,
Env: env, Env: env,
Image: service.Image, Healthcheck: healthcheckFromCompose(service.HealthCheck),
Init: service.Init, Image: service.Image,
Privileged: service.Privileged, Init: service.Init,
PullPolicy: pullPolicy, Privileged: service.Privileged,
Resources: resourcesFromCompose(service), PullPolicy: pullPolicy,
Sysctls: service.Sysctls, Resources: resourcesFromCompose(service),
User: service.User, Sysctls: service.Sysctls,
User: service.User,
}, },
Name: serviceName, Name: serviceName,
Mode: api.ServiceModeReplicated, Mode: api.ServiceModeReplicated,
@@ -97,6 +100,22 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
default: default:
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode) return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
} }
if cfg := service.Deploy.UpdateConfig; cfg != nil {
switch cfg.Order {
case "":
// No order specified, use default behavior.
case "start-first":
spec.UpdateConfig.Order = api.UpdateOrderStartFirst
case "stop-first":
spec.UpdateConfig.Order = api.UpdateOrderStopFirst
default:
return spec, fmt.Errorf("unsupported deploy.update_config.order: '%s'", cfg.Order)
}
d := time.Duration(cfg.Monitor)
spec.UpdateConfig.MonitorPeriod = &d
}
} }
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well? // TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
@@ -120,11 +139,61 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
return spec, nil return spec, nil
} }
func healthcheckFromCompose(hc *types.HealthCheckConfig) *api.HealthcheckSpec {
if hc == nil {
return nil
}
if hc.Disable {
return &api.HealthcheckSpec{Disable: true}
}
spec := &api.HealthcheckSpec{Test: hc.Test}
if hc.Interval != nil {
spec.Interval = time.Duration(*hc.Interval)
}
if hc.Timeout != nil {
spec.Timeout = time.Duration(*hc.Timeout)
}
if hc.StartPeriod != nil {
spec.StartPeriod = time.Duration(*hc.StartPeriod)
}
if hc.StartInterval != nil {
spec.StartInterval = time.Duration(*hc.StartInterval)
}
if hc.Retries != nil {
spec.Retries = uint(*hc.Retries)
}
return spec
}
func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources { func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
resources := api.ContainerResources{ resources := api.ContainerResources{
CPU: int64(service.CPUS * 1e9), CPU: int64(service.CPUS * 1e9),
Memory: int64(service.MemLimit), Memory: int64(service.MemLimit),
MemoryReservation: int64(service.MemReservation), MemoryReservation: int64(service.MemReservation),
Ulimits: ulimitsFromCompose(service.Ulimits),
}
// Convert device mappings, separating CDI devices from regular device mappings.
// CDI devices are identified when Source == Target and the source is a qualified CDI name.
var cdiDeviceNames []string
for _, dev := range service.Devices {
if dev.Source == dev.Target && cdi.IsQualifiedName(dev.Source) {
cdiDeviceNames = append(cdiDeviceNames, dev.Source)
continue
}
resources.Devices = append(resources.Devices, api.DeviceMapping{
HostPath: dev.Source,
ContainerPath: dev.Target,
CgroupPermissions: dev.Permissions,
})
}
if len(cdiDeviceNames) > 0 {
resources.DeviceReservations = append(resources.DeviceReservations, container.DeviceRequest{
Driver: "cdi",
DeviceIDs: cdiDeviceNames,
})
} }
// Convert GPU device requests from compose format, appending "gpu" capability. // Convert GPU device requests from compose format, appending "gpu" capability.
@@ -293,6 +362,33 @@ func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.Vol
return spec return spec
} }
func ulimitsFromCompose(ulimits map[string]*types.UlimitsConfig) map[string]api.Ulimit {
if len(ulimits) == 0 {
return nil
}
res := make(map[string]api.Ulimit, len(ulimits))
for name, u := range ulimits {
soft := u.Soft
hard := u.Hard
if u.Single != 0 {
if soft == 0 {
soft = u.Single
}
if hard == 0 {
hard = u.Single
}
}
res[name] = api.Ulimit{
Soft: int64(soft),
Hard: int64(hard),
}
}
return res
}
// validateServicesExtensions validates extension combinations across all services in the project. // validateServicesExtensions validates extension combinations across all services in the project.
func validateServicesExtensions(project *types.Project) error { func validateServicesExtensions(project *types.Project) error {
for _, service := range project.Services { for _, service := range project.Services {
+426 -1
View File
@@ -8,6 +8,7 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
composecli "github.com/compose-spec/compose-go/v2/cli" composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -93,7 +94,7 @@ func TestServiceSpecFromCompose(t *testing.T) {
}, },
}, },
{ {
name: "full-spec", name: "full spec",
filename: "compose-full-spec.yaml", filename: "compose-full-spec.yaml",
want: map[string]api.ServiceSpec{ want: map[string]api.ServiceSpec{
"test": { "test": {
@@ -109,6 +110,14 @@ func TestServiceSpecFromCompose(t *testing.T) {
"EMPTY": "", "EMPTY": "",
"VAR": "value", "VAR": "value",
}, },
Healthcheck: &api.HealthcheckSpec{
Test: []string{"CMD", "curl", "-f", "http://localhost"},
Interval: 1*time.Minute + 30*time.Second,
Timeout: 10 * time.Second,
Retries: 5,
StartPeriod: 15 * time.Second,
StartInterval: 2 * time.Second,
},
Image: "nginx:latest", Image: "nginx:latest",
Init: &initTrue, Init: &initTrue,
LogDriver: &api.LogDriver{ LogDriver: &api.LogDriver{
@@ -124,6 +133,17 @@ func TestServiceSpecFromCompose(t *testing.T) {
CPU: 0.5 * api.Core, CPU: 0.5 * api.Core,
Memory: 100 * units.MiB, Memory: 100 * units.MiB,
MemoryReservation: 50 * units.MiB, MemoryReservation: 50 * units.MiB,
Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 20000, Hard: 40000},
"nproc": {Soft: 65535, Hard: 65535},
},
Devices: []api.DeviceMapping{
{HostPath: "/dev/ttyUSB0", ContainerPath: "/dev/ttyUSB0", CgroupPermissions: "rw"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
},
DeviceReservations: []container.DeviceRequest{
{Count: -1, Capabilities: [][]string{{"gpu"}}},
},
}, },
Sysctls: map[string]string{ Sysctls: map[string]string{
"net.ipv4.ip_forward": "1", "net.ipv4.ip_forward": "1",
@@ -139,6 +159,15 @@ func TestServiceSpecFromCompose(t *testing.T) {
VolumeName: "data1", VolumeName: "data1",
ContainerPath: "/data1", ContainerPath: "/data1",
}, },
{
VolumeName: "bind-53f1acbf1de61e9e608c93effca23791674e463d02bb7aaca7c625804aef1926",
ContainerPath: "/path/in/container",
ReadOnly: true,
},
{
VolumeName: "data3-labeled",
ContainerPath: "/data3",
},
{ {
VolumeName: "data2-alias", VolumeName: "data2-alias",
ContainerPath: "/data2/long/syntax", ContainerPath: "/data2/long/syntax",
@@ -173,8 +202,24 @@ func TestServiceSpecFromCompose(t *testing.T) {
Mode: api.PortModeHost, Mode: api.PortModeHost,
}, },
}, },
Placement: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
Replicas: 3, Replicas: 3,
UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
Volumes: []api.VolumeSpec{ Volumes: []api.VolumeSpec{
{
Name: "bind-53f1acbf1de61e9e608c93effca23791674e463d02bb7aaca7c625804aef1926",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/path/on/host",
CreateHostPath: true,
Propagation: mount.Propagation("rprivate"),
},
},
{ {
Name: "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90", Name: "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90",
Type: api.VolumeTypeBind, Type: api.VolumeTypeBind,
@@ -207,11 +252,22 @@ func TestServiceSpecFromCompose(t *testing.T) {
}, },
}, },
}, },
{
Name: "data3-labeled",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "data3-labeled",
NoCopy: true,
SubPath: "app/data",
Labels: map[string]string{"env": "test"},
},
},
{ {
Name: "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542", Name: "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542",
Type: api.VolumeTypeTmpfs, Type: api.VolumeTypeTmpfs,
TmpfsOptions: &mount.TmpfsOptions{ TmpfsOptions: &mount.TmpfsOptions{
SizeBytes: 10 * units.MiB, SizeBytes: 10 * units.MiB,
Mode: os.FileMode(1770),
}, },
}, },
}, },
@@ -257,6 +313,8 @@ func TestServiceSpecFromCompose(t *testing.T) {
} }
func TestServiceSpecFromCompose_Caddy(t *testing.T) { func TestServiceSpecFromCompose_Caddy(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
composeYAML string composeYAML string
@@ -405,6 +463,8 @@ services:
} }
func TestServiceSpecFromCompose_GPUs(t *testing.T) { func TestServiceSpecFromCompose_GPUs(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
composeYAML string composeYAML string
@@ -592,6 +652,8 @@ services:
} }
func TestServiceSpecFromCompose_VolumeDriverOpts(t *testing.T) { func TestServiceSpecFromCompose_VolumeDriverOpts(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
composeYAML string composeYAML string
@@ -699,7 +761,269 @@ volumes:
} }
} }
func TestServiceSpecFromCompose_Ulimits(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expected map[string]api.Ulimit
}{
{
name: "single ulimit with soft and hard limits",
composeYAML: `
services:
db:
image: postgres
ulimits:
nofile:
soft: 20000
hard: 40000
`,
expected: map[string]api.Ulimit{
"nofile": {
Soft: 20000,
Hard: 40000,
},
},
},
{
name: "single ulimit with single value (soft=hard)",
composeYAML: `
services:
db:
image: postgres
ulimits:
nproc: 65535
`,
expected: map[string]api.Ulimit{
"nproc": {
Soft: 65535,
Hard: 65535,
},
},
},
{
name: "multiple ulimits",
composeYAML: `
services:
db:
image: postgres
ulimits:
nofile:
soft: 20000
hard: 40000
nproc: 65535
`,
expected: map[string]api.Ulimit{
"nofile": {
Soft: 20000,
Hard: 40000,
},
"nproc": {
Soft: 65535,
Hard: 65535,
},
},
},
{
name: "empty ulimits",
composeYAML: `
services:
db:
image: postgres
ulimits: {}
`,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "db")
require.NoError(t, err)
assert.Equal(t, tt.expected, spec.Container.Resources.Ulimits)
})
}
}
func TestServiceSpecFromCompose_UpdateConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expected api.UpdateConfig
expectError bool
}{
{
name: "no update_config",
composeYAML: `
services:
test:
image: nginx
`,
expected: api.UpdateConfig{},
},
{
name: "empty update_config",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config: {}
`,
expected: api.UpdateConfig{
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with unsupported attributes ignored",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
parallelism: 1
`,
expected: api.UpdateConfig{
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with stop-first order",
composeYAML: `
services:
test:
image: postgres
deploy:
update_config:
order: stop-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with start-first order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: start-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStartFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with invalid order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: invalid-order
`,
expectError: true,
},
{
name: "update_config with replicas and order",
composeYAML: `
services:
test:
image: nginx
deploy:
replicas: 3
update_config:
order: stop-first
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
},
},
{
name: "update_config with custom monitor",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
monitor: 10s
`,
expected: api.UpdateConfig{
MonitorPeriod: api.AsPtr(10 * time.Second),
},
},
{
name: "update_config with monitor and order",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
order: start-first
monitor: 30s
`,
expected: api.UpdateConfig{
Order: api.UpdateOrderStartFirst,
MonitorPeriod: api.AsPtr(30 * time.Second),
},
},
{
name: "update_config with zero monitor skips monitoring",
composeYAML: `
services:
test:
image: nginx
deploy:
update_config:
monitor: 0s
`,
expected: api.UpdateConfig{
MonitorPeriod: api.AsPtr(time.Duration(0)),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "test")
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, spec.UpdateConfig)
})
}
}
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) { func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
composeYAML string composeYAML string
@@ -856,3 +1180,104 @@ services:
}) })
} }
} }
func TestServiceSpecFromCompose_Devices(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expectedDevices []api.DeviceMapping
expectedReservations []container.DeviceRequest
}{
{
name: "simple device",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/dri
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
},
},
{
name: "device with target and permissions",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/sda:/dev/xvda:r
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "r"},
},
},
{
name: "multiple devices",
composeYAML: `
services:
test:
image: nginx
devices:
- "/dev/ttyUSB0:/dev/ttyUSB0:rw"
- /dev/sda:/dev/xvda
- "/dev/dri"
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/ttyUSB0", ContainerPath: "/dev/ttyUSB0", CgroupPermissions: "rw"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
},
},
{
name: "CDI device",
composeYAML: `
services:
test:
image: nginx
devices:
- vendor.com/class=device1
`,
expectedReservations: []container.DeviceRequest{
{Driver: "cdi", DeviceIDs: []string{"vendor.com/class=device1"}},
},
},
{
name: "mixed CDI and regular devices",
composeYAML: `
services:
test:
image: nginx
devices:
- /dev/dri
- vendor.com/class=device1
- nvidia.com/gpu=0
- /dev/sda:/dev/xvda:r
`,
expectedDevices: []api.DeviceMapping{
{HostPath: "/dev/dri", ContainerPath: "/dev/dri", CgroupPermissions: "rwm"},
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "r"},
},
expectedReservations: []container.DeviceRequest{
{Driver: "cdi", DeviceIDs: []string{"vendor.com/class=device1", "nvidia.com/gpu=0"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "test")
require.NoError(t, err)
assert.Equal(t, tt.expectedDevices, spec.Container.Resources.Devices)
assert.Equal(t, tt.expectedReservations, spec.Container.Resources.DeviceReservations)
})
}
}
+37
View File
@@ -6,11 +6,25 @@ services:
- ALL - ALL
command: ["nginx", "updated", "command"] command: ["nginx", "updated", "command"]
cpus: 0.5 cpus: 0.5
deploy:
update_config:
order: stop-first
devices:
- /dev/ttyUSB0:/dev/ttyUSB0:rw
- /dev/sda:/dev/xvda
entrypoint: ["/updated-docker-entrypoint.sh"] entrypoint: ["/updated-docker-entrypoint.sh"]
environment: environment:
BOOL: "true" BOOL: "true"
EMPTY: "" EMPTY: ""
VAR: value VAR: value
gpus: all
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 1m30s
timeout: 10s
retries: 5
start_period: 15s
start_interval: 2s
image: nginx:latest image: nginx:latest
init: true init: true
logging: logging:
@@ -25,10 +39,28 @@ services:
scale: 3 scale: 3
sysctls: sysctls:
- net.ipv4.ip_forward=1 - net.ipv4.ip_forward=1
ulimits:
nofile:
soft: 20000
hard: 40000
nproc: 65535
user: nginx:nginx user: nginx:nginx
volumes: volumes:
- /etc/passwd:/host/etc/passwd:ro - /etc/passwd:/host/etc/passwd:ro
- data1:/data1 - data1:/data1
- type: bind
source: /path/on/host
target: /path/in/container
read_only: true
bind:
create_host_path: true
propagation: rprivate
- type: volume
source: data3-labeled
target: /data3
volume:
nocopy: true
subpath: app/data
- type: volume - type: volume
source: data2-alias source: data2-alias
target: /data2/long/syntax target: /data2/long/syntax
@@ -37,6 +69,8 @@ services:
target: /tmpfs target: /tmpfs
tmpfs: tmpfs:
size: 10485760 size: 10485760
mode: 1770
x-machines: ["machine-1", "machine-2"]
x-ports: x-ports:
- test.example.com:80/https - test.example.com:80/https
- 8000/http - 8000/http
@@ -55,5 +89,8 @@ volumes:
data2-alias: data2-alias:
name: data2 name: data2
driver: local driver: local
data3-labeled:
labels:
env: test
data-external: data-external:
external: true external: true
+15 -1
View File
@@ -24,6 +24,16 @@ type SSHConnectorConfig struct {
SockPath string SockPath string
} }
// Destination returns the destination string that can be passed to ssh CLI in the format [user@]host.
func (cfg *SSHConnectorConfig) Destination() string {
dst := cfg.Host
if cfg.User != "" {
dst = fmt.Sprintf("%s@%s", cfg.User, dst)
}
return dst
}
// SSHConnector establishes a connection to the machine API through an SSH tunnel to the machine. // SSHConnector establishes a connection to the machine API through an SSH tunnel to the machine.
type SSHConnector struct { type SSHConnector struct {
config SSHConnectorConfig config SSHConnectorConfig
@@ -48,7 +58,11 @@ func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
var err error var err error
c.client, err = sshexec.Connect(c.config.User, c.config.Host, c.config.Port, c.config.KeyPath) c.client, err = sshexec.Connect(c.config.User, c.config.Host, c.config.Port, c.config.KeyPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("SSH login to %s@%s:%d: %w", c.config.User, c.config.Host, c.config.Port, err) dst := c.config.Destination()
if c.config.Port != 0 {
dst = fmt.Sprintf("%s:%d", dst, c.config.Port)
}
return nil, fmt.Errorf("SSH login to %s: %w", dst, err)
} }
} }
+127 -71
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"os"
"path/filepath"
"strconv" "strconv"
"github.com/docker/cli/cli/connhelper/commandconn" "github.com/docker/cli/cli/connhelper/commandconn"
@@ -17,101 +19,96 @@ import (
// and running `uncloudd dial-stdio` on the remote machine. // and running `uncloudd dial-stdio` on the remote machine.
type SSHCLIConnector struct { type SSHCLIConnector struct {
config SSHConnectorConfig config SSHConnectorConfig
conn net.Conn // Path to SSH control socket for connection reuse.
controlSockPath string
} }
func NewSSHCLIConnector(cfg *SSHConnectorConfig) *SSHCLIConnector { func NewSSHCLIConnector(cfg *SSHConnectorConfig) *SSHCLIConnector {
return &SSHCLIConnector{config: *cfg} return &SSHCLIConnector{
config: *cfg,
controlSockPath: controlSocketPath(),
}
} }
// sshCLIDialer implements proxy.ContextDialer by spawning SSH processes with -W flag. // controlSocketPath returns a unique control socket path for the SSH connection.
type sshCLIDialer struct { // Returns an empty string if unable to find or create a suitable path.
config SSHConnectorConfig func controlSocketPath() string {
} // %C is expanded by `ssh` to a hash of user, local and remote hostnames, port, and the contents
// of the ProxyJump option. This ensures that shared connections are uniquely identified.
sockName := fmt.Sprintf("uc_control_%%C.sock")
// buildDialArgs constructs SSH command arguments for -W flag dialing. // Prefer XDG_RUNTIME_DIR if set, fall back to ~/.ssh if it exists.
func (d *sshCLIDialer) buildDialArgs(address string) []string { if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
args := []string{} return filepath.Join(dir, sockName)
}
// Add connection timeout to fail fast when node is down. if home, err := os.UserHomeDir(); err == nil {
args = append(args, "-o", "ConnectTimeout=5") sshDir := filepath.Join(home, ".ssh")
if fi, sErr := os.Stat(sshDir); sErr == nil && fi.IsDir() {
// Add port if non-standard. return filepath.Join(sshDir, sockName)
if d.config.Port != 0 && d.config.Port != 22 { }
args = append(args, "-p", strconv.Itoa(d.config.Port))
} }
// Add identity file if specified. // Last resort: create a subdirectory in temp with restricted permissions.
if d.config.KeyPath != "" { tmpDir := filepath.Join(os.TempDir(), fmt.Sprintf("uncloud-%d", os.Getuid()))
args = append(args, "-i", d.config.KeyPath) path := filepath.Join(tmpDir, sockName)
if len(path)-2+40 < 104 { // 40 chars for %C hash, 104 is typical UNIX socket path limit
if err := os.MkdirAll(tmpDir, 0o700); err == nil {
return path
}
} }
// Add -W flag for stdin/stdout forwarding to target address. return ""
args = append(args, "-W", address)
// Add user@host.
args = append(args, d.config.User+"@"+d.config.Host)
return args
}
// DialContext establishes a connection to the target address through an SSH tunnel using -W flag.
func (d *sshCLIDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
// Only support TCP connections.
if network != "tcp" {
return nil, fmt.Errorf("unsupported network type: %s", network)
}
// Build SSH command arguments.
args := d.buildDialArgs(address)
// Create connection using commandconn.
conn, err := commandconn.New(ctx, "ssh", args...)
if err != nil {
return nil, fmt.Errorf("SSH connection to %s@%s for dialing %s: %w", d.config.User, d.config.Host, address, err)
}
return conn, nil
} }
func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) { func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
// Build SSH command arguments. // Create gRPC client with a dialer that spawns a new SSH connection on demand.
args := c.buildSSHArgs() // Each dial attempt runs `ssh ... uncloudd dial-stdio`, reusing the control socket if available.
// Create connection using commandconn.
conn, err := commandconn.New(ctx, "ssh", args...)
if err != nil {
return nil, fmt.Errorf("SSH connection to %s@%s: %w", c.config.User, c.config.Host, err)
}
c.conn = conn
// Create gRPC client over the connection.
// Use a custom dialer that returns our existing connection.
grpcConn, err := grpc.NewClient( grpcConn, err := grpc.NewClient(
"passthrough:///", // Dummy target since we're using a custom dialer. "passthrough:///", // Dummy target since we're using a custom dialer.
grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(defaultServiceConfig), grpc.WithDefaultServiceConfig(defaultServiceConfig),
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return c.conn, nil args := c.buildSSHArgs()
conn, err := commandconn.New(ctx, "ssh", args...)
if err != nil {
return nil, fmt.Errorf("SSH connection to %s: %w", c.config.Destination(), err)
}
return conn, nil
}), }),
) )
if err != nil { if err != nil {
c.conn.Close()
return nil, fmt.Errorf("create machine API client: %w", err) return nil, fmt.Errorf("create machine API client: %w", err)
} }
return grpcConn, nil return grpcConn, nil
} }
// buildSSHArgs constructs the SSH command arguments. // buildSSHArgs constructs the SSH command arguments to run `uncloudd dial-stdio` on the remote machine reusing
// the established connection via control socket.
func (c *SSHCLIConnector) buildSSHArgs() []string { func (c *SSHCLIConnector) buildSSHArgs() []string {
args := []string{} var args []string
// Add control socket options for connection reuse if available.
if c.controlSockPath != "" {
args = append(args, "-o", "ControlMaster=auto")
args = append(args, "-o", "ControlPath="+c.controlSockPath)
// Keep the established connection alive for a short duration after the last session closes to allow reuse.
controlPersist := "10m"
// Override the default duration with the UNCLOUD_SSH_CONTROL_PERSIST env variable.
if v := os.Getenv("UNCLOUD_SSH_CONTROL_PERSIST"); v != "" {
controlPersist = v
}
args = append(args, "-o", "ControlPersist="+controlPersist)
}
// Add connection timeout to fail fast when node is down. // Add connection timeout to fail fast when node is down.
args = append(args, "-o", "ConnectTimeout=5") args = append(args, "-o", "ConnectTimeout=5")
// Disable pseudo-terminal allocation to prevent SSH from executing as a login shell.
args = append(args, "-T")
// Add port if non-standard. // Add port if specified.
if c.config.Port != 0 && c.config.Port != 22 { if c.config.Port != 0 {
args = append(args, "-p", strconv.Itoa(c.config.Port)) args = append(args, "-p", strconv.Itoa(c.config.Port))
} }
@@ -120,8 +117,8 @@ func (c *SSHCLIConnector) buildSSHArgs() []string {
args = append(args, "-i", c.config.KeyPath) args = append(args, "-i", c.config.KeyPath)
} }
// Add user@host. // Add [user@]host destination.
args = append(args, c.config.User+"@"+c.config.Host) args = append(args, c.config.Destination())
// Add remote command: uncloudd dial-stdio // Add remote command: uncloudd dial-stdio
args = append(args, "uncloudd", "dial-stdio") args = append(args, "uncloudd", "dial-stdio")
@@ -141,15 +138,74 @@ func (c *SSHCLIConnector) Dialer() (proxy.ContextDialer, error) {
} }
return &sshCLIDialer{ return &sshCLIDialer{
config: c.config, config: c.config,
controlSockPath: c.controlSockPath,
}, nil }, nil
} }
func (c *SSHCLIConnector) Close() error { func (c *SSHCLIConnector) Close() error {
if c.conn != nil { // Individual connections are managed by gRPC and closed when the gRPC connection closes.
err := c.conn.Close() // The SSH control socket may persist for connection reuse across CLI invocations.
c.conn = nil
return err
}
return nil return nil
} }
// sshCLIDialer implements proxy.ContextDialer by spawning SSH processes with -W flag.
type sshCLIDialer struct {
config SSHConnectorConfig
// Shared control socket path from SSHCLIConnector for connection reuse.
controlSockPath string
}
// buildDialArgs constructs SSH command arguments for -W flag dialing.
func (d *sshCLIDialer) buildDialArgs(address string) []string {
var args []string
if d.controlSockPath != "" {
// Try to reuse the existing control connection without initiating a new one.
// Falls back to direct connection if the control socket is not available.
args = append(args, "-o", "ControlMaster=no")
args = append(args, "-o", "ControlPath="+d.controlSockPath)
}
// Add connection timeout to fail fast when node is down.
args = append(args, "-o", "ConnectTimeout=5")
// Disable pseudo-terminal allocation to prevent SSH from executing as a login shell.
args = append(args, "-T")
// Add port if specified.
if d.config.Port != 0 {
args = append(args, "-p", strconv.Itoa(d.config.Port))
}
// Add identity file if specified.
if d.config.KeyPath != "" {
args = append(args, "-i", d.config.KeyPath)
}
// Add -W flag for stdin/stdout forwarding to target address.
args = append(args, "-W", address)
// Add [user@]host destination.
args = append(args, d.config.Destination())
return args
}
// DialContext establishes a connection to the target address through an SSH tunnel using -W flag.
func (d *sshCLIDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
// Only support TCP connections.
if network != "tcp" {
return nil, fmt.Errorf("unsupported network type: %s", network)
}
// Build SSH command arguments.
args := d.buildDialArgs(address)
// Create connection using docker's commandconn.
conn, err := commandconn.New(ctx, "ssh", args...)
if err != nil {
return nil, fmt.Errorf("SSH connection to %s for dialing %s: %w", d.config.Destination(), address, err)
}
return conn, nil
}
+96 -33
View File
@@ -1,6 +1,7 @@
package connector package connector
import ( import (
"strings"
"testing" "testing"
"github.com/psviderski/uncloud/internal/machine" "github.com/psviderski/uncloud/internal/machine"
@@ -11,17 +12,28 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
config SSHConnectorConfig config SSHConnectorConfig
expected []string controlSockPath string
expected []string
}{ }{
{ {
name: "basic connection", name: "basic connection with control socket",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
}, },
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio"},
},
{
name: "basic connection without control socket",
config: SSHConnectorConfig{
User: "root",
Host: "example.com",
},
controlSockPath: "",
expected: []string{"-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio"},
}, },
{ {
name: "with custom port", name: "with custom port",
@@ -30,7 +42,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
Host: "example.com", Host: "example.com",
Port: 2222, Port: 2222,
}, },
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "root@example.com", "uncloudd", "dial-stdio"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "root@example.com", "uncloudd", "dial-stdio"},
}, },
{ {
name: "with identity file", name: "with identity file",
@@ -39,7 +52,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
Host: "example.com", Host: "example.com",
KeyPath: "/path/to/key", KeyPath: "/path/to/key",
}, },
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio"},
}, },
{ {
name: "with custom socket path", name: "with custom socket path",
@@ -48,7 +62,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
Host: "example.com", Host: "example.com",
SockPath: "/custom/path/uncloud.sock", SockPath: "/custom/path/uncloud.sock",
}, },
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"},
}, },
{ {
name: "with default socket path (not included)", name: "with default socket path (not included)",
@@ -57,7 +72,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
Host: "example.com", Host: "example.com",
SockPath: machine.DefaultUncloudSockPath, SockPath: machine.DefaultUncloudSockPath,
}, },
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio"},
}, },
{ {
name: "all options combined", name: "all options combined",
@@ -68,16 +84,28 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
KeyPath: "/path/to/key", KeyPath: "/path/to/key",
SockPath: "/custom/path/uncloud.sock", SockPath: "/custom/path/uncloud.sock",
}, },
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"},
}, },
{ {
name: "port 22 not included (default)", name: "port 0 not included",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
Port: 0, Port: 0,
}, },
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"}, controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio"},
},
{
name: "port 22 included when explicit",
config: SSHConnectorConfig{
User: "root",
Host: "example.com",
Port: 22,
},
controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-T", "-p", "22", "root@example.com", "uncloudd", "dial-stdio"},
}, },
} }
@@ -85,7 +113,7 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
c := &SSHCLIConnector{config: tt.config} c := &SSHCLIConnector{config: tt.config, controlSockPath: tt.controlSockPath}
got := c.buildSSHArgs() got := c.buildSSHArgs()
assert.Equal(t, tt.expected, got) assert.Equal(t, tt.expected, got)
}) })
@@ -96,61 +124,77 @@ func TestSSHCLIDialer_buildDialArgs(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
config SSHConnectorConfig config SSHConnectorConfig
address string controlSockPath string
expected []string address string
expected []string
}{ }{
{ {
name: "basic connection", name: "basic connection without control socket",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
}, },
address: "10.210.1.1:5000", controlSockPath: "",
expected: []string{"-o", "ConnectTimeout=5", "-W", "10.210.1.1:5000", "root@example.com"}, address: "10.210.1.1:5000",
expected: []string{"-o", "ConnectTimeout=5", "-T", "-W", "10.210.1.1:5000", "root@example.com"},
}, },
{ {
name: "custom port", name: "basic connection with control socket",
config: SSHConnectorConfig{
User: "root",
Host: "example.com",
},
controlSockPath: "/tmp/test.sock",
address: "10.210.1.1:5000",
expected: []string{"-o", "ControlMaster=no", "-o", "ControlPath=/tmp/test.sock", "-o", "ConnectTimeout=5", "-T", "-W", "10.210.1.1:5000", "root@example.com"},
},
{
name: "custom port with control socket",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
Port: 2222, Port: 2222,
}, },
address: "10.210.1.1:5000", controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-W", "10.210.1.1:5000", "root@example.com"}, address: "10.210.1.1:5000",
expected: []string{"-o", "ControlMaster=no", "-o", "ControlPath=/tmp/test.sock", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "-W", "10.210.1.1:5000", "root@example.com"},
}, },
{ {
name: "with identity file", name: "with identity file and control socket",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
Port: 22, Port: 22,
KeyPath: "/home/user/.ssh/id_rsa", KeyPath: "/home/user/.ssh/id_rsa",
}, },
address: "10.210.1.1:5000", controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ConnectTimeout=5", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"}, address: "10.210.1.1:5000",
expected: []string{"-o", "ControlMaster=no", "-o", "ControlPath=/tmp/test.sock", "-o", "ConnectTimeout=5", "-T", "-p", "22", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"},
}, },
{ {
name: "custom port with identity file", name: "custom port with identity file and control socket",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
Port: 2222, Port: 2222,
KeyPath: "/home/user/.ssh/id_rsa", KeyPath: "/home/user/.ssh/id_rsa",
}, },
address: "10.210.1.1:5000", controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"}, address: "10.210.1.1:5000",
expected: []string{"-o", "ControlMaster=no", "-o", "ControlPath=/tmp/test.sock", "-o", "ConnectTimeout=5", "-T", "-p", "2222", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"},
}, },
{ {
name: "zero port defaults to 22", name: "port 0 not included",
config: SSHConnectorConfig{ config: SSHConnectorConfig{
User: "root", User: "root",
Host: "example.com", Host: "example.com",
Port: 0, Port: 0,
}, },
address: "10.210.1.1:5000", controlSockPath: "/tmp/test.sock",
expected: []string{"-o", "ConnectTimeout=5", "-W", "10.210.1.1:5000", "root@example.com"}, address: "10.210.1.1:5000",
expected: []string{"-o", "ControlMaster=no", "-o", "ControlPath=/tmp/test.sock", "-o", "ConnectTimeout=5", "-T", "-W", "10.210.1.1:5000", "root@example.com"},
}, },
} }
@@ -158,9 +202,28 @@ func TestSSHCLIDialer_buildDialArgs(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
d := &sshCLIDialer{config: tt.config} d := &sshCLIDialer{config: tt.config, controlSockPath: tt.controlSockPath}
got := d.buildDialArgs(tt.address) got := d.buildDialArgs(tt.address)
assert.Equal(t, tt.expected, got) assert.Equal(t, tt.expected, got)
}) })
} }
} }
func TestControlSocketPath(t *testing.T) {
// Note: Cannot use t.Parallel() because a subtest uses t.Setenv().
path1 := controlSocketPath()
path2 := controlSocketPath()
assert.Equal(t, path1, path2)
assert.True(t, strings.HasSuffix(path1, ".sock"))
assert.Contains(t, path1, "%C")
t.Run("uses XDG_RUNTIME_DIR when set", func(t *testing.T) {
runDir := "/user/runtime/dir"
t.Setenv("XDG_RUNTIME_DIR", runDir)
path := controlSocketPath()
assert.True(t, strings.HasPrefix(path, runDir))
})
}
+151
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/containerd/errdefs" "github.com/containerd/errdefs"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
@@ -17,6 +18,9 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
// TODO: format container and machine IDs in 'Container %s on %s' events as bold.
// Consider formatting containers as <service_name>/<short-container-id>.
// CreateContainer creates a new container for the given service on the specified machine. // CreateContainer creates a new container for the given service on the specified machine.
func (cli *Client) CreateContainer( func (cli *Client) CreateContainer(
ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string, ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string,
@@ -354,3 +358,150 @@ func (cli *Client) ExecContainer(
return exitCode, nil return exitCode, nil
} }
// WaitContainerHealthy polls the container until it is considered running and healthy.
//
// For containers without a health check, it waits for the monitor period and then verifies the container
// is still running and not restarting.
//
// For containers with a health check, it waits until Docker reports healthy or unhealthy. During the monitor period,
// unhealthy status is treated as retryable (the container may be recovering from a transient crash).
// After the monitor period, unhealthy becomes a permanent failure.
func (cli *Client) WaitContainerHealthy(
ctx context.Context, serviceNameOrID, containerNameOrID string, opts api.WaitContainerHealthyOptions,
) error {
// First inspect to get container info, machine name, and health check config.
mc, err := cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
if err != nil {
return fmt.Errorf("inspect container: %w", err)
}
machine, err := cli.InspectMachine(ctx, mc.MachineID)
if err != nil {
return fmt.Errorf("inspect machine '%s': %w", mc.MachineID, err)
}
pw := progress.ContextWriter(ctx)
eventID := fmt.Sprintf("Container %s on %s", mc.Container.Name, machine.Machine.Name)
var monitor time.Duration
if opts.MonitorPeriod == nil {
monitor = api.DefaultHealthMonitorPeriod
} else {
monitor = *opts.MonitorPeriod
}
pw.Event(progress.NewEvent(eventID, progress.Working, fmt.Sprintf("Monitoring (%s)", monitor)))
// For containers without a health check, just wait for the monitor period and then check the container
// is still running and not restarting.
if !mc.Container.HasHealthcheck() {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(monitor):
}
mc, err := cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
if err != nil {
return fmt.Errorf("inspect container: %w", err)
}
if mc.Container.Healthy() {
pw.Event(progress.RunningEvent(eventID))
return nil
}
humanState, _ := mc.Container.HumanState()
pw.Event(progress.ErrorMessageEvent(eventID, fmt.Sprintf("Unhealthy (%s)", humanState)))
if mc.Container.State.Restarting {
return fmt.Errorf("container is restarting after monitor period (%s): exit_code=%d",
monitor, mc.Container.State.ExitCode)
}
return fmt.Errorf("container is unhealthy after monitor period (%s): %s", monitor, humanState)
}
// For containers with a health check, wait until Docker reports healthy or unhealthy.
mctx := proxyToMachine(ctx, machine.Machine)
mctx, cancel := context.WithTimeout(mctx, healthcheckTimeout(mc.Container.Config.Healthcheck))
defer cancel()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
monitorDeadline := time.Now().Add(monitor)
for {
select {
case <-mctx.Done():
return mctx.Err()
case <-ticker.C:
ctr, err := cli.Docker.InspectServiceContainer(mctx, mc.Container.ID)
if err != nil {
pw.Event(progress.NewEvent(eventID, progress.Working,
fmt.Sprintf("Health checking (failed to inspect container: %v)", err)))
continue
}
// Reset the event status if previous inspect failed.
eventStatus := fmt.Sprintf("Monitoring (%s)", monitor)
if time.Now().After(monitorDeadline) {
// TODO: provide more details about running checks or waiting so the user can see what's going on.
eventStatus = "Health checking"
}
pw.Event(progress.NewEvent(eventID, progress.Working, eventStatus))
if ctr.Healthy() {
pw.Event(progress.Healthy(eventID))
return nil
}
if time.Now().Before(monitorDeadline) {
continue
}
if ctr.State.Health.Status == container.Unhealthy {
humanState, _ := ctr.HumanState()
pw.Event(progress.ErrorMessageEvent(eventID, fmt.Sprintf("Unhealthy (%s)", humanState)))
if ctr.State.Restarting {
return fmt.Errorf("container is restarting after monitor period (%s): exit_code=%d",
monitor, ctr.State.ExitCode)
}
return fmt.Errorf("container is unhealthy after monitor period (%s): %s", monitor, humanState)
}
}
}
}
const (
// defaultDockerHealthcheckInterval is the default Docker interval between health check runs.
defaultDockerHealthcheckInterval = 30 * time.Second
// defaultDockerHealthcheckTimeout is the default Docker timeout for each health check run.
defaultDockerHealthcheckTimeout = 30 * time.Second
// defaultDockerHealthcheckRetries is the default Docker number of consecutive failures needed
// to consider the container unhealthy.
defaultDockerHealthcheckRetries = 3
)
// healthcheckTimeout computes the maximum time to wait for a container to become healthy based on
// its health check config. This is the worst case timeout to stop polling in case something goes wrong and Docker
// doesn't report the container as unhealthy after it should.
func healthcheckTimeout(hc *container.HealthConfig) time.Duration {
if hc == nil {
return 0
}
interval := hc.Interval
if interval <= 0 {
interval = defaultDockerHealthcheckInterval
}
timeout := hc.Timeout
if timeout <= 0 {
timeout = defaultDockerHealthcheckTimeout
}
retries := hc.Retries
if retries <= 0 {
retries = defaultDockerHealthcheckRetries
}
// 5s is a buffer to account for scheduling delays.
return hc.StartPeriod + time.Duration(retries)*(interval+timeout) + 5*time.Second
}
+8 -1
View File
@@ -83,10 +83,17 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
} }
} }
// Device reservations are immutable, so we'll need to recreate if any have changed // Device reservations and mappings are immutable, so we'll need to recreate if any have changed
if !reflect.DeepEqual(current.Container.Resources.DeviceReservations, newResources.DeviceReservations) { if !reflect.DeepEqual(current.Container.Resources.DeviceReservations, newResources.DeviceReservations) {
return ContainerNeedsRecreate return ContainerNeedsRecreate
} }
if !reflect.DeepEqual(current.Container.Resources.Devices, newResources.Devices) {
return ContainerNeedsRecreate
}
// Ulimits are immutable, so we'll need to recreate if any have changed.
if !reflect.DeepEqual(current.Container.Resources.Ulimits, newResources.Ulimits) {
return ContainerNeedsRecreate
}
// Check if any mutable properties changed. // Check if any mutable properties changed.
if !current.Caddy.Equals(new.Caddy) { if !current.Caddy.Equals(new.Caddy) {
+141
View File
@@ -1410,6 +1410,68 @@ func TestEvalContainerSpecChange_Volumes(t *testing.T) {
} }
} }
func TestEvalContainerSpecChange_Devices(t *testing.T) {
t.Parallel()
tests := []struct {
name string
current api.ContainerResources
new api.ContainerResources
want ContainerSpecStatus
}{
{
name: "empty",
current: api.ContainerResources{},
new: api.ContainerResources{},
want: ContainerUpToDate,
},
{
name: "identical mapping",
current: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/foo", CgroupPermissions: "rwm"}}},
new: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/foo", CgroupPermissions: "rwm"}}},
want: ContainerUpToDate,
},
{
name: "add mapping",
current: api.ContainerResources{},
new: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/foo", CgroupPermissions: "rwm"}}},
want: ContainerNeedsRecreate,
},
{
name: "remove mapping",
current: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/foo", CgroupPermissions: "rwm"}}},
new: api.ContainerResources{},
want: ContainerNeedsRecreate,
},
{
name: "change mapping path",
current: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/foo", CgroupPermissions: "rwm"}}},
new: api.ContainerResources{Devices: []api.DeviceMapping{{HostPath: "/dev/foo", ContainerPath: "/dev/bar", CgroupPermissions: "rwm"}}},
want: ContainerNeedsRecreate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
currentSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: tt.current,
},
}
newSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: tt.new,
},
}
result := EvalContainerSpecChange(currentSpec, newSpec)
assert.Equal(t, tt.want, result)
})
}
}
func TestEvalContainerSpecChange_DeviceReservations(t *testing.T) { func TestEvalContainerSpecChange_DeviceReservations(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1802,6 +1864,85 @@ func TestEvalContainerSpecChange_DeviceReservations(t *testing.T) {
} }
} }
func TestEvalContainerSpecChange_Ulimits(t *testing.T) {
t.Parallel()
tests := []struct {
name string
current api.ContainerResources
new api.ContainerResources
want ContainerSpecStatus
}{
{
name: "empty",
current: api.ContainerResources{},
new: api.ContainerResources{},
want: ContainerUpToDate,
},
{
name: "identical single ulimit",
current: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
new: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
want: ContainerUpToDate,
},
{
name: "set ulimit",
current: api.ContainerResources{},
new: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
want: ContainerNeedsRecreate,
},
{
name: "remove ulimit",
current: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
new: api.ContainerResources{},
want: ContainerNeedsRecreate,
},
{
name: "change ulimit soft value",
current: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
new: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 30000, Hard: 40000}}},
want: ContainerNeedsRecreate,
},
{
name: "change ulimit hard value",
current: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 40000}}},
new: api.ContainerResources{Ulimits: map[string]api.Ulimit{"nofile": {Soft: 20000, Hard: 80000}}},
want: ContainerNeedsRecreate,
},
{
name: "add ulimit",
current: api.ContainerResources{Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 20000, Hard: 40000},
}},
new: api.ContainerResources{Ulimits: map[string]api.Ulimit{
"nofile": {Soft: 20000, Hard: 40000},
"nproc": {Soft: 65535, Hard: 65535},
}},
want: ContainerNeedsRecreate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
currentSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: tt.current,
},
}
newSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: tt.new,
},
}
result := EvalContainerSpecChange(currentSpec, newSpec)
assert.Equal(t, tt.want, result)
})
}
}
func TestEvalContainerSpecChange_Mixed(t *testing.T) { func TestEvalContainerSpecChange_Mixed(t *testing.T) {
t.Parallel() t.Parallel()
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
) )
@@ -33,7 +34,7 @@ type Deployment struct {
type Plan struct { type Plan struct {
ServiceID string ServiceID string
ServiceName string ServiceName string
SequenceOperation operation.SequenceOperation
} }
// NewDeployment creates a new deployment for the given service specification. // NewDeployment creates a new deployment for the given service specification.
-191
View File
@@ -1,191 +0,0 @@
package deploy
import (
"context"
"fmt"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/pkg/api"
)
// Operation represents a single atomic operation in a deployment process.
// Operations can be composed to form complex deployment strategies.
type Operation interface {
// Execute performs the operation using the provided client.
// TODO: Encapsulate the client in the operation as otherwise it gives an impression that different clients
// can be provided. But in reality, the operation is tightly coupled with the client that was used to create it.
Execute(ctx context.Context, cli Client) error
// Format returns a human-readable representation of the operation.
// TODO: get rid of the resolver and assign the required names for formatting in the operation itself.
Format(resolver NameResolver) string
String() string
}
// NameResolver resolves machine and container IDs to their names.
type NameResolver interface {
MachineName(machineID string) string
ContainerName(containerID string) string
}
// TODO: pass api.ServiceContainer to operations to simplify operation formatting in the plan.
// RunContainerOperation creates and starts a new container on a specific machine.
type RunContainerOperation struct {
ServiceID string
Spec api.ServiceSpec
MachineID string
}
func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error {
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
if err != nil {
return fmt.Errorf("create container: %w", err)
}
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
return fmt.Errorf("start container: %w", err)
}
// TODO: wait for the container to become healthy
return nil
}
func (o *RunContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Run container [image=%s]", machineName, o.Spec.Container.Image)
}
func (o *RunContainerOperation) String() string {
return fmt.Sprintf("RunContainerOperation[machine_id=%s service_id=%s image=%s]",
o.MachineID, o.ServiceID, o.Spec.Container.Image)
}
// StopContainerOperation stops a container on a specific machine.
type StopContainerOperation struct {
ServiceID string
ContainerID string
MachineID string
}
func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error {
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
return fmt.Errorf("stop container: %w", err)
}
return nil
}
func (o *StopContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Stop container [id=%s name=%s]", machineName,
o.ContainerID[:12], resolver.ContainerName(o.ContainerID))
}
func (o *StopContainerOperation) String() string {
return fmt.Sprintf("StopContainerOperation[machine_id=%s service_id=%s container_id=%s]",
o.MachineID, o.ServiceID, o.ContainerID)
}
// RemoveContainerOperation stops and removes a container from a specific machine.
type RemoveContainerOperation struct {
MachineID string
Container api.ServiceContainer
}
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) error {
if err := cli.StopContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.StopOptions{}); err != nil {
return fmt.Errorf("stop container: %w", err)
}
if err := cli.RemoveContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.RemoveOptions{
// Remove anonymous volumes created by the container.
RemoveVolumes: true,
}); err != nil {
return fmt.Errorf("remove container: %w", err)
}
return nil
}
func (o *RemoveContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Remove container [id=%s image=%s]",
machineName, o.Container.ShortID(), o.Container.Config.Image)
}
func (o *RemoveContainerOperation) String() string {
return fmt.Sprintf("RemoveContainerOperation[machine_id=%s service_id=%s container_id=%s]",
o.MachineID, o.Container.ServiceID(), o.Container.ID)
}
// CreateVolumeOperation creates a volume on a specific machine.
type CreateVolumeOperation struct {
VolumeSpec api.VolumeSpec
MachineID string
// MachineName is used for formatting the operation output only.
MachineName string
}
func (o *CreateVolumeOperation) Execute(ctx context.Context, cli Client) error {
if o.VolumeSpec.Type != api.VolumeTypeVolume {
return fmt.Errorf("invalid volume type: '%s', expected '%s'", o.VolumeSpec.Type, api.VolumeTypeVolume)
}
opts := volume.CreateOptions{
Name: o.VolumeSpec.DockerVolumeName(),
}
if o.VolumeSpec.VolumeOptions != nil {
if o.VolumeSpec.VolumeOptions.Driver != nil {
opts.Driver = o.VolumeSpec.VolumeOptions.Driver.Name
opts.DriverOpts = o.VolumeSpec.VolumeOptions.Driver.Options
}
opts.Labels = o.VolumeSpec.VolumeOptions.Labels
}
if _, err := cli.CreateVolume(ctx, o.MachineID, opts); err != nil {
return fmt.Errorf("create volume: %w", err)
}
return nil
}
func (o *CreateVolumeOperation) Format(_ NameResolver) string {
return fmt.Sprintf("%s: Create volume [name=%s]", o.MachineName, o.VolumeSpec.DockerVolumeName())
}
func (o *CreateVolumeOperation) String() string {
return fmt.Sprintf("CreateVolumeOperation[machine_id=%s volume=%s]",
o.MachineID, o.VolumeSpec.DockerVolumeName())
}
// SequenceOperation is a composite operation that executes a sequence of operations in order.
type SequenceOperation struct {
Operations []Operation
}
func (o *SequenceOperation) Execute(ctx context.Context, cli Client) error {
for _, op := range o.Operations {
if err := op.Execute(ctx, cli); err != nil {
return err
}
}
return nil
}
func (o *SequenceOperation) Format(resolver NameResolver) string {
ops := make([]string, len(o.Operations))
for i, op := range o.Operations {
ops[i] = "- " + op.Format(resolver)
}
return strings.Join(ops, "\n")
}
func (o *SequenceOperation) String() string {
ops := make([]string, len(o.Operations))
for i, op := range o.Operations {
ops[i] = op.String()
}
return fmt.Sprintf("SequenceOperation[%s]", strings.Join(ops, ", "))
}
+211
View File
@@ -0,0 +1,211 @@
package operation
import (
"context"
"fmt"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stringid"
"github.com/psviderski/uncloud/pkg/api"
)
// RunContainerOperation creates and starts a new container on a specific machine.
type RunContainerOperation struct {
ServiceID string
Spec api.ServiceSpec
MachineID string
// SkipHealthMonitor skips the monitoring period and health checks after starting a container.
SkipHealthMonitor bool
}
func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error {
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
if err != nil {
return fmt.Errorf("create container: %w", err)
}
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
return fmt.Errorf("start container: %w", err)
}
if o.SkipHealthMonitor {
return nil
}
opts := api.WaitContainerHealthyOptions{MonitorPeriod: o.Spec.UpdateConfig.MonitorPeriod}
if err = cli.WaitContainerHealthy(ctx, o.ServiceID, resp.ID, opts); err != nil {
return fmt.Errorf("container '%s/%s' failed to become healthy: %w",
o.Spec.Name, stringid.TruncateID(resp.ID), err)
}
return nil
}
func (o *RunContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Run container [image=%s]", machineName, o.Spec.Container.Image)
}
func (o *RunContainerOperation) String() string {
return fmt.Sprintf("RunContainerOperation[machine_id=%s service_id=%s image=%s]",
o.MachineID, o.ServiceID, o.Spec.Container.Image)
}
// StopContainerOperation stops a container on a specific machine.
type StopContainerOperation struct {
ServiceID string
ContainerID string
MachineID string
}
func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error {
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
return fmt.Errorf("stop container: %w", err)
}
return nil
}
func (o *StopContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Stop container [id=%s name=%s]", machineName,
o.ContainerID[:12], resolver.ContainerName(o.ContainerID))
}
func (o *StopContainerOperation) String() string {
return fmt.Sprintf("StopContainerOperation[machine_id=%s service_id=%s container_id=%s]",
o.MachineID, o.ServiceID, o.ContainerID)
}
// RemoveContainerOperation stops and removes a container from a specific machine.
type RemoveContainerOperation struct {
MachineID string
Container api.ServiceContainer
}
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) error {
if err := cli.StopContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.StopOptions{}); err != nil {
return fmt.Errorf("stop container: %w", err)
}
if err := cli.RemoveContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.RemoveOptions{
// Remove anonymous volumes created by the container.
RemoveVolumes: true,
}); err != nil {
return fmt.Errorf("remove container: %w", err)
}
return nil
}
func (o *RemoveContainerOperation) Format(resolver NameResolver) string {
machineName := resolver.MachineName(o.MachineID)
return fmt.Sprintf("%s: Remove container [id=%s image=%s]",
machineName, o.Container.ShortID(), o.Container.Config.Image)
}
func (o *RemoveContainerOperation) String() string {
return fmt.Sprintf("RemoveContainerOperation[machine_id=%s service_id=%s container_id=%s]",
o.MachineID, o.Container.ServiceID(), o.Container.ID)
}
// ReplaceContainerOperation replaces an old container with a new one based on the specified update order.
// For start-first: starts new container, then removes old container.
// For stop-first: stops old container, starts new container, then removes old container.
type ReplaceContainerOperation struct {
ServiceID string
Spec api.ServiceSpec
MachineID string
OldContainer api.ServiceContainer
// Order specifies the update order: "start-first" or "stop-first".
Order string
// SkipHealthMonitor skips the monitoring period and health checks after starting a new container.
SkipHealthMonitor bool
}
func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) error {
stopFirst := o.Order == api.UpdateOrderStopFirst
wasRunning := false
if stopFirst {
// Inspect the old container to remember its running state before stopping.
ctr, err := cli.InspectContainer(ctx, o.ServiceID, o.OldContainer.ID)
if err != nil {
return fmt.Errorf("inspect old container: %w", err)
}
wasRunning = ctr.Container.State.Running
if wasRunning {
if err = cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, container.StopOptions{}); err != nil {
return fmt.Errorf("stop old container: %w", err)
}
}
}
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
if err != nil {
return fmt.Errorf("create new container: %w", err)
}
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
return fmt.Errorf("start new container: %w", err)
}
if !o.SkipHealthMonitor {
opts := api.WaitContainerHealthyOptions{MonitorPeriod: o.Spec.UpdateConfig.MonitorPeriod}
if err = cli.WaitContainerHealthy(ctx, o.ServiceID, resp.ID, opts); err != nil {
// New container failed to become healthy. Stop it and roll back to the previous container.
// Don't remove the new stopped container to allow users to inspect logs and state.
// TODO: collect logs from the new container and include in the error message to speed up debugging.
// Use context without progress to not overwrite the container Unhealthy status with Stopped.
ctxWithoutProgress := progress.WithContextWriter(ctx, nil)
_ = cli.StopContainer(ctxWithoutProgress, o.ServiceID, resp.ID, container.StopOptions{})
newCtr := fmt.Sprintf("%s/%s", o.Spec.Name, stringid.TruncateID(resp.ID))
healthErr := fmt.Errorf(
"new container '%s' failed to become healthy: %w. "+
"It's stopped and available for inspection. Fetch logs with 'uc logs %s'",
newCtr, err, o.Spec.Name,
)
if stopFirst && wasRunning {
// Restart the old container only if it was running before we stopped it.
oldCtr := fmt.Sprintf("%s/%s", o.OldContainer.ServiceSpec.Name, o.OldContainer.ShortID())
if rollbackErr := cli.StartContainer(ctx, o.ServiceID, o.OldContainer.ID); rollbackErr != nil {
return fmt.Errorf("%w. Rolled back to old container '%s' but failed to restart it: %w",
healthErr, oldCtr, rollbackErr)
}
return fmt.Errorf("%w. Rolled back to old container '%s'", healthErr, oldCtr)
}
return healthErr
}
}
// For start-first, we need to stop before removing.
// For stop-first, the container is already stopped.
if !stopFirst {
// TODO: the new container is propagated to Caddy upstreams through the cluster store asynchronously.
// There still might be a brief downtime (for a 1 replica service) when Caddy doesn't know about
// the new container but we're stopping the old container. We should somehow ensure Caddy is updated
// with the new container before we stop the old one to avoid this downtime.
if err := cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, container.StopOptions{}); err != nil {
return fmt.Errorf("stop old container: %w", err)
}
}
if err := cli.RemoveContainer(ctx, o.ServiceID, o.OldContainer.ID, container.RemoveOptions{
RemoveVolumes: true,
}); err != nil {
return fmt.Errorf("remove old container: %w", err)
}
return nil
}
func (o *ReplaceContainerOperation) Format(resolver NameResolver) string {
return fmt.Sprintf("%s: Replace container [id=%s image=%s order=%s]",
resolver.MachineName(o.MachineID), o.OldContainer.ShortID(), o.Spec.Container.Image, o.Order)
}
func (o *ReplaceContainerOperation) String() string {
return fmt.Sprintf("ReplaceContainerOperation[machine_id=%s service_id=%s old_container_id=%s order=%s]",
o.MachineID, o.ServiceID, o.OldContainer.ID, o.Order)
}
+30
View File
@@ -0,0 +1,30 @@
package operation
import (
"context"
"github.com/psviderski/uncloud/pkg/api"
)
// Operation represents a single atomic operation in a deployment process.
// Operations can be composed to form complex deployment strategies.
type Operation interface {
// Execute performs the operation using the provided client.
Execute(ctx context.Context, cli Client) error
// Format returns a human-readable representation of the operation.
// TODO: get rid of the resolver and assign the required names for formatting in the operation itself.
Format(resolver NameResolver) string
String() string
}
// Client defines the interface required to execute deployment operations.
type Client interface {
api.ContainerClient
api.VolumeClient
}
// NameResolver resolves machine and container IDs to their names.
type NameResolver interface {
MachineName(machineID string) string
ContainerName(containerID string) string
}
+39
View File
@@ -0,0 +1,39 @@
package operation
import (
"context"
"fmt"
"strings"
)
// SequenceOperation is a composite operation that executes a sequence of operations in order.
type SequenceOperation struct {
Operations []Operation
}
func (o *SequenceOperation) Execute(ctx context.Context, cli Client) error {
for _, op := range o.Operations {
if err := op.Execute(ctx, cli); err != nil {
return err
}
}
return nil
}
func (o *SequenceOperation) Format(resolver NameResolver) string {
ops := make([]string, len(o.Operations))
for i, op := range o.Operations {
ops[i] = "- " + op.Format(resolver)
}
return strings.Join(ops, "\n")
}
func (o *SequenceOperation) String() string {
ops := make([]string, len(o.Operations))
for i, op := range o.Operations {
ops[i] = op.String()
}
return fmt.Sprintf("SequenceOperation[%s]", strings.Join(ops, ", "))
}
+49
View File
@@ -0,0 +1,49 @@
package operation
import (
"context"
"fmt"
"github.com/docker/docker/api/types/volume"
"github.com/psviderski/uncloud/pkg/api"
)
// CreateVolumeOperation creates a volume on a specific machine.
type CreateVolumeOperation struct {
VolumeSpec api.VolumeSpec
MachineID string
// MachineName is used for formatting the operation output only.
MachineName string
}
func (o *CreateVolumeOperation) Execute(ctx context.Context, cli Client) error {
if o.VolumeSpec.Type != api.VolumeTypeVolume {
return fmt.Errorf("invalid volume type: '%s', expected '%s'", o.VolumeSpec.Type, api.VolumeTypeVolume)
}
opts := volume.CreateOptions{
Name: o.VolumeSpec.DockerVolumeName(),
}
if o.VolumeSpec.VolumeOptions != nil {
if o.VolumeSpec.VolumeOptions.Driver != nil {
opts.Driver = o.VolumeSpec.VolumeOptions.Driver.Name
opts.DriverOpts = o.VolumeSpec.VolumeOptions.Driver.Options
}
opts.Labels = o.VolumeSpec.VolumeOptions.Labels
}
if _, err := cli.CreateVolume(ctx, o.MachineID, opts); err != nil {
return fmt.Errorf("create volume: %w", err)
}
return nil
}
func (o *CreateVolumeOperation) Format(_ NameResolver) string {
return fmt.Sprintf("%s: Create volume [name=%s]", o.MachineName, o.VolumeSpec.DockerVolumeName())
}
func (o *CreateVolumeOperation) String() string {
return fmt.Sprintf("CreateVolumeOperation[machine_id=%s volume=%s]",
o.MachineID, o.VolumeSpec.DockerVolumeName())
}
+121 -28
View File
@@ -11,6 +11,7 @@ import (
// VolumeScheduler determines what missing volumes should be created and where for a multi-service deployment. // VolumeScheduler determines what missing volumes should be created and where for a multi-service deployment.
// It satisfies the following constraints: // It satisfies the following constraints:
// - Volumes used by global services will be created on all eligible machines.
// - Services that share a volume must be placed on the same machine where the volume is located. // - Services that share a volume must be placed on the same machine where the volume is located.
// If the volume is located on multiple machines, services can be placed on any of them. // If the volume is located on multiple machines, services can be placed on any of them.
// - Services must respect their individual placement constraints. // - Services must respect their individual placement constraints.
@@ -128,12 +129,20 @@ func (s *VolumeScheduler) Schedule() (map[string][]api.VolumeSpec, error) {
serviceEligibleMachines[spec.Name] = machineIDs serviceEligibleMachines[spec.Name] = machineIDs
} }
// For each volume that exists on any machine(s) (which shouldn't be created), intersect each service's // For each volume that exists on any machine(s), intersect each non-global service's
// eligible machines that use the volume with the machines the volume is located on. // eligible machines that use the volume with the machines the volume is located on.
// Global services skip this constraint as they need the volume on ALL eligible machines,
// and the volume will be created on machines that don't have it.
// //
// Service name -> list of processed volume names (quoted) to format the error message. // Service name -> list of processed volume names (quoted) to format the error message.
quotedServiceVolumes := make(map[string][]string) quotedServiceVolumes := make(map[string][]string)
for volumeName, volumeMachines := range s.existingVolumeMachines { for volumeName, volumeMachines := range s.existingVolumeMachines {
// Skip constraint narrowing for global services - they don't need to be constrained
// to machines that already have the volume.
if s.isVolumeForGlobalService(volumeName) {
continue
}
for _, serviceName := range s.volumeServices[volumeName] { for _, serviceName := range s.volumeServices[volumeName] {
quotedServiceVolumes[serviceName] = append(quotedServiceVolumes[serviceName], quotedServiceVolumes[serviceName] = append(quotedServiceVolumes[serviceName],
fmt.Sprintf("'%s'", volumeName)) fmt.Sprintf("'%s'", volumeName))
@@ -147,52 +156,97 @@ func (s *VolumeScheduler) Schedule() (map[string][]api.VolumeSpec, error) {
} }
} }
// Skip constraints propagation for volumes that already exist on machines as the propagation only works // Skip constraints propagation for:
// for missing volumes. // 1. Volumes that already exist on machines (for replicated services) as the propagation only works
// for missing volumes. Global service volumes are NOT marked as placed here since they still need
// to be scheduled on machines that don't have them.
// 2. Volumes only used by global services - these need UNION of eligible machines, not intersection.
placedVolumes := make(map[string]struct{}) placedVolumes := make(map[string]struct{})
for volumeName := range s.existingVolumeMachines { for volumeName := range s.existingVolumeMachines {
placedVolumes[volumeName] = struct{}{} if !s.isVolumeForGlobalService(volumeName) {
placedVolumes[volumeName] = struct{}{}
}
}
// Skip constraint propagation for volumes used by global services.
// Also check for invalid configuration: volume shared between global and replicated services.
for volumeName := range s.volumeSpecs {
if s.isVolumeSharedBetweenGlobalAndReplicated(volumeName) {
return nil, fmt.Errorf("volume '%s' cannot be shared between global and replicated services: "+
"global services require the volume on all machines while replicated services require "+
"co-location with the volume", volumeName)
}
if s.isVolumeForGlobalService(volumeName) {
placedVolumes[volumeName] = struct{}{}
}
} }
if err := s.propagateConstraintsUntilConvergence(serviceEligibleMachines, placedVolumes); err != nil { if err := s.propagateConstraintsUntilConvergence(serviceEligibleMachines, placedVolumes); err != nil {
return nil, err return nil, err
} }
// Schedule each missing volume on one of its eligible machines. // Schedule each missing volume on eligible machines.
// For global services: schedule on ALL eligible machines that don't already have the volume.
// For replicated services: schedule on ONE eligible machine (skip if volume exists anywhere).
scheduledVolumes := make(map[string][]api.VolumeSpec) scheduledVolumes := make(map[string][]api.VolumeSpec)
for missingVolumeName, missingVolumeSpec := range s.volumeSpecs { for volumeName, volumeSpec := range s.volumeSpecs {
// Skip volumes that already exist on machines. existingMachines := s.existingVolumeMachines[volumeName]
if _, ok := s.existingVolumeMachines[missingVolumeName]; ok { serviceNames := s.volumeServices[volumeName]
continue
}
serviceNames := s.volumeServices[missingVolumeName]
if len(serviceNames) == 0 { if len(serviceNames) == 0 {
return nil, fmt.Errorf("bug detected: no services using volume '%s'", missingVolumeName) return nil, fmt.Errorf("bug detected: no services using volume '%s'", volumeName)
} }
// Get the current eligible machines (any service using the volume will have the same set after convergence). // Get the eligible machines for this volume.
eligibleMachines := serviceEligibleMachines[serviceNames[0]] // For volumes used by global services: compute UNION of all services' eligible machines.
// For other volumes: any service will have the same set after constraint convergence.
var eligibleMachines mapset.Set[string]
if s.isVolumeForGlobalService(volumeName) {
// Compute union of eligible machines for all global services using this volume.
eligibleMachines = mapset.NewSet[string]()
for _, serviceName := range serviceNames {
eligibleMachines = eligibleMachines.Union(serviceEligibleMachines[serviceName])
}
} else {
eligibleMachines = serviceEligibleMachines[serviceNames[0]]
}
if eligibleMachines.Cardinality() == 0 { if eligibleMachines.Cardinality() == 0 {
return nil, fmt.Errorf("bug detected: no eligible machines for volume '%s'", missingVolumeName) return nil, fmt.Errorf("bug detected: no eligible machines for volume '%s'", volumeName)
} }
// Choose the first machine in the sorted eligible machines to schedule the volume on.
// Sort the eligible machines to ensure deterministic behavior. // Sort the eligible machines to ensure deterministic behavior.
sortedEligibleMachines := eligibleMachines.ToSlice() sortedEligibleMachines := eligibleMachines.ToSlice()
slices.Sort(sortedEligibleMachines) slices.Sort(sortedEligibleMachines)
machineID := sortedEligibleMachines[0]
// Update constraints for all services that use this volume to be placed on the selected machine.
for _, serviceName := range serviceNames {
serviceEligibleMachines[serviceName] = mapset.NewSet(machineID)
}
placedVolumes[missingVolumeName] = struct{}{}
scheduledVolumes[machineID] = append(scheduledVolumes[machineID], missingVolumeSpec)
// Propagate the updated constraints. if s.isVolumeForGlobalService(volumeName) {
if err := s.propagateConstraintsUntilConvergence(serviceEligibleMachines, placedVolumes); err != nil { // Global service: schedule volume on eligible machines that don't already have it.
return nil, fmt.Errorf("unexpected error while propagating constraints after "+ for _, machineID := range sortedEligibleMachines {
"scheduling volume '%s' on machine '%s': %w", missingVolumeName, machineID, err) if existingMachines != nil && existingMachines.Contains(machineID) {
// Volume already exists on this machine, skip it.
continue
}
scheduledVolumes[machineID] = append(scheduledVolumes[machineID], volumeSpec)
}
// Mark volume as placed - no constraint propagation needed since volume will be on all machines.
placedVolumes[volumeName] = struct{}{}
} else {
// Replicated service: skip if volume already exists on any machine (services will use that location).
if existingMachines != nil && existingMachines.Cardinality() > 0 {
continue
}
// Schedule volume on ONE machine (first in sorted order).
machineID := sortedEligibleMachines[0]
// Update constraints for all services that use this volume to be placed on the selected machine.
for _, serviceName := range serviceNames {
serviceEligibleMachines[serviceName] = mapset.NewSet(machineID)
}
placedVolumes[volumeName] = struct{}{}
scheduledVolumes[machineID] = append(scheduledVolumes[machineID], volumeSpec)
// Propagate the updated constraints.
if err := s.propagateConstraintsUntilConvergence(serviceEligibleMachines, placedVolumes); err != nil {
return nil, fmt.Errorf("unexpected error while propagating constraints after "+
"scheduling volume '%s' on machine '%s': %w", volumeName, machineID, err)
}
} }
} }
@@ -298,3 +352,42 @@ func (s *VolumeScheduler) propagateConstraintsUntilConvergence(
return nil return nil
} }
// isVolumeForGlobalService returns true if any service using this volume is a global service.
func (s *VolumeScheduler) isVolumeForGlobalService(volumeName string) bool {
serviceNames := s.volumeServices[volumeName]
for _, serviceName := range serviceNames {
for _, spec := range s.serviceSpecs {
if spec.Name == serviceName && spec.Mode == api.ServiceModeGlobal {
return true
}
}
}
return false
}
// isVolumeSharedBetweenGlobalAndReplicated returns true if a volume is used by both
// global and replicated services, which is an invalid configuration.
func (s *VolumeScheduler) isVolumeSharedBetweenGlobalAndReplicated(volumeName string) bool {
serviceNames := s.volumeServices[volumeName]
hasGlobal := false
hasReplicated := false
for _, serviceName := range serviceNames {
for _, spec := range s.serviceSpecs {
if spec.Name == serviceName {
mode := spec.Mode
if mode == "" {
mode = api.ServiceModeReplicated
}
if mode == api.ServiceModeGlobal {
hasGlobal = true
} else {
hasReplicated = true
}
}
}
}
return hasGlobal && hasReplicated
}
+390
View File
@@ -802,6 +802,396 @@ func TestVolumeScheduler_Schedule(t *testing.T) {
}, },
}, },
}, },
{
name: "global service with missing volume schedules on all machines",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
{
Info: &pb.MachineInfo{
Id: "machine3",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service",
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "vol1",
ContainerPath: "/data",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
want: map[string][]api.VolumeSpec{
"machine1": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
"machine2": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
"machine3": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
{
name: "global service with volume on some machines schedules remaining",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
Volumes: []volume.Volume{
{
Name: "vol1",
},
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
{
Info: &pb.MachineInfo{
Id: "machine3",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service",
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "vol1",
ContainerPath: "/data",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
want: map[string][]api.VolumeSpec{
"machine2": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
"machine3": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
{
name: "global service with placement constraint",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
{
Info: &pb.MachineInfo{
Id: "machine3",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service",
Mode: api.ServiceModeGlobal,
Placement: api.Placement{
Machines: []string{"machine1", "machine3"},
},
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "vol1",
ContainerPath: "/data",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
want: map[string][]api.VolumeSpec{
"machine1": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
"machine3": {
{
Name: "vol1",
Type: api.VolumeTypeVolume,
},
},
},
},
{
name: "volume shared between global and replicated fails",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service",
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
{
Name: "replicated-service",
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
},
wantErr: "volume 'shared-vol' cannot be shared between global and replicated services",
},
{
name: "multiple global services sharing same volume",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service-1",
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data1",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
{
Name: "global-service-2",
Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data2",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
},
want: map[string][]api.VolumeSpec{
"machine1": {
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
"machine2": {
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
},
{
name: "multiple global services sharing same volume with different placement constraints",
machines: []*Machine{
{
Info: &pb.MachineInfo{
Id: "machine1",
},
},
{
Info: &pb.MachineInfo{
Id: "machine2",
},
},
{
Info: &pb.MachineInfo{
Id: "machine3",
},
},
},
serviceSpecs: []api.ServiceSpec{
{
Name: "global-service-1",
Mode: api.ServiceModeGlobal,
Placement: api.Placement{
Machines: []string{"machine1", "machine2"},
},
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data1",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
{
Name: "global-service-2",
Mode: api.ServiceModeGlobal,
Placement: api.Placement{
Machines: []string{"machine2", "machine3"},
},
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
VolumeMounts: []api.VolumeMount{
{
VolumeName: "shared-vol",
ContainerPath: "/data2",
},
},
},
Volumes: []api.VolumeSpec{
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
},
want: map[string][]api.VolumeSpec{
"machine1": {
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
"machine2": {
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
"machine3": {
{
Name: "shared-vol",
Type: api.VolumeTypeVolume,
},
},
},
},
} }
for _, tt := range tests { for _, tt := range tests {
+116 -62
View File
@@ -8,6 +8,7 @@ import (
"github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler" "github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
) )
@@ -28,6 +29,8 @@ type RollingStrategy struct {
// ForceRecreate indicates whether all containers should be recreated during the deployment, // ForceRecreate indicates whether all containers should be recreated during the deployment,
// regardless of whether their specifications have changed. // regardless of whether their specifications have changed.
ForceRecreate bool ForceRecreate bool
// SkipHealthMonitor skips the monitoring period and health checks for faster emergency deployments.
SkipHealthMonitor bool
// state is the current and planned state of the cluster used for scheduling decisions. // state is the current and planned state of the cluster used for scheduling decisions.
state *scheduler.ClusterState state *scheduler.ClusterState
@@ -144,10 +147,11 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
if len(containers) == 0 { if len(containers) == 0 {
// No more existing containers on this machine, create a new one. // No more existing containers on this machine, create a new one.
plan.Operations = append(plan.Operations, &RunContainerOperation{ plan.Operations = append(plan.Operations, &operation.RunContainerOperation{
ServiceID: plan.ServiceID, ServiceID: plan.ServiceID,
Spec: spec, Spec: spec,
MachineID: m.Id, MachineID: m.Id,
SkipHealthMonitor: s.SkipHealthMonitor,
}) })
continue continue
} }
@@ -160,36 +164,24 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
continue continue
} }
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported. // TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
conflictingPorts, portsErr := ctr.ConflictingServicePorts(spec.Ports)
if portsErr != nil || len(conflictingPorts) > 0 {
// Stop the malformed container or the container with conflicting ports.
plan.Operations = append(plan.Operations, &StopContainerOperation{
ServiceID: plan.ServiceID,
ContainerID: ctr.ID,
MachineID: m.Id,
})
}
} }
// Run a new container. // Replace the old container with a new one.
plan.Operations = append(plan.Operations, &RunContainerOperation{ order := determineUpdateOrder(ctr, spec)
ServiceID: plan.ServiceID, plan.Operations = append(plan.Operations, &operation.ReplaceContainerOperation{
Spec: spec, ServiceID: plan.ServiceID,
MachineID: m.Id, Spec: spec,
}) MachineID: m.Id,
OldContainer: ctr,
// Remove the old container. Order: order,
plan.Operations = append(plan.Operations, &RemoveContainerOperation{ SkipHealthMonitor: s.SkipHealthMonitor,
MachineID: m.Id,
Container: ctr,
}) })
} }
// Remove any remaining containers that are not needed. // Remove any remaining containers that are not needed.
for mid, containers := range containersOnMachine { for mid, containers := range containersOnMachine {
for _, c := range containers { for _, c := range containers {
plan.Operations = append(plan.Operations, &RemoveContainerOperation{ plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
MachineID: mid, MachineID: mid,
Container: c, Container: c,
}) })
@@ -228,7 +220,8 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
for _, m := range availableMachines { for _, m := range availableMachines {
containers := containersOnMachine[m.Info.Id] containers := containersOnMachine[m.Info.Id]
ops, err := reconcileGlobalContainer(containers, spec, plan.ServiceID, m.Info.Id, s.ForceRecreate) ops, err := reconcileGlobalContainer(
containers, spec, plan.ServiceID, m.Info.Id, s.ForceRecreate, s.SkipHealthMonitor)
if err != nil { if err != nil {
return plan, err return plan, err
} }
@@ -240,7 +233,7 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
// Remove any remaining containers on machines that don't match the new placement constraints. // Remove any remaining containers on machines that don't match the new placement constraints.
for _, containers := range containersOnMachine { for _, containers := range containersOnMachine {
for _, c := range containers { for _, c := range containers {
plan.Operations = append(plan.Operations, &RemoveContainerOperation{ plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
MachineID: c.MachineID, MachineID: c.MachineID,
Container: c.Container, Container: c.Container,
}) })
@@ -254,16 +247,18 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
// It ensures exactly one container with the desired spec is running on the machine by creating a new container and // It ensures exactly one container with the desired spec is running on the machine by creating a new container and
// removing old ones. If there is a host port conflict, it stops the old container before starting a new one. // removing old ones. If there is a host port conflict, it stops the old container before starting a new one.
func reconcileGlobalContainer( func reconcileGlobalContainer(
containers []api.MachineServiceContainer, spec api.ServiceSpec, serviceID, machineID string, forceRecreate bool, containers []api.MachineServiceContainer, spec api.ServiceSpec, serviceID, machineID string,
) ([]Operation, error) { forceRecreate, skipHealthCheck bool,
var ops []Operation ) ([]operation.Operation, error) {
var ops []operation.Operation
if len(containers) == 0 { if len(containers) == 0 {
// No containers on this machine, create a new one. // No containers on this machine, create a new one.
ops = append(ops, &RunContainerOperation{ ops = append(ops, &operation.RunContainerOperation{
ServiceID: serviceID, ServiceID: serviceID,
Spec: spec, Spec: spec,
MachineID: machineID, MachineID: machineID,
SkipHealthMonitor: skipHealthCheck,
}) })
return ops, nil return ops, nil
} }
@@ -290,7 +285,7 @@ func reconcileGlobalContainer(
if i == j { if i == j {
continue continue
} }
ops = append(ops, &RemoveContainerOperation{ ops = append(ops, &operation.RemoveContainerOperation{
MachineID: old.MachineID, MachineID: old.MachineID,
Container: old.Container, Container: old.Container,
}) })
@@ -304,43 +299,102 @@ func reconcileGlobalContainer(
} }
// The machine has containers but none of them match the new spec. // The machine has containers but none of them match the new spec.
// Stop the old running containers that have conflicting ports with the new spec before running a new one. // Find the first running container to replace (there should typically be only one).
for _, c := range containers { var containerToReplace *api.MachineServiceContainer
for i, c := range containers {
if c.Container.State.Running { if c.Container.State.Running {
conflictingPorts, err := c.Container.ConflictingServicePorts(spec.Ports) containerToReplace = &containers[i]
if err != nil { break
return nil, fmt.Errorf("check conflicting ports: %w", err)
}
if len(conflictingPorts) > 0 {
// Stop the running container with conflicting ports.
ops = append(ops, &StopContainerOperation{
ServiceID: serviceID,
ContainerID: c.Container.ID,
MachineID: c.MachineID,
})
}
} }
} }
// Run a new container. if containerToReplace != nil {
ops = append(ops, &RunContainerOperation{ // Stop any other running containers that have conflicting ports before replacing the container.
ServiceID: serviceID, // This handles the edge case where multiple running containers exist (due to bugs or interrupted deployments)
Spec: spec, // and more than one has ports that conflict with the new spec.
MachineID: machineID, for _, c := range containers {
}) if c.Container.ID == containerToReplace.Container.ID || !c.Container.State.Running {
continue
}
conflictingPorts, err := c.Container.ConflictingServicePorts(spec.Ports)
if err != nil || len(conflictingPorts) > 0 {
ops = append(ops, &operation.StopContainerOperation{
ServiceID: serviceID,
ContainerID: c.Container.ID,
MachineID: machineID,
})
}
}
// Remove the old containers. // Replace the running container with a new one.
for _, c := range containers { order := determineUpdateOrder(containerToReplace.Container, spec)
ops = append(ops, &RemoveContainerOperation{ ops = append(ops, &operation.ReplaceContainerOperation{
MachineID: c.MachineID, ServiceID: serviceID,
Container: c.Container, Spec: spec,
MachineID: machineID,
OldContainer: containerToReplace.Container,
Order: order,
SkipHealthMonitor: skipHealthCheck,
}) })
// Remove any other containers (there shouldn't be any in normal operation).
for _, c := range containers {
if c.Container.ID == containerToReplace.Container.ID {
continue
}
ops = append(ops, &operation.RemoveContainerOperation{
MachineID: c.MachineID,
Container: c.Container,
})
}
} else {
// No running containers, create a new one and remove all stopped containers.
ops = append(ops, &operation.RunContainerOperation{
ServiceID: serviceID,
Spec: spec,
MachineID: machineID,
SkipHealthMonitor: skipHealthCheck,
})
for _, c := range containers {
ops = append(ops, &operation.RemoveContainerOperation{
MachineID: c.MachineID,
Container: c.Container,
})
}
} }
return ops, nil return ops, nil
} }
// determineUpdateOrder determines the update order for replacing a container based on the service spec
// and current container state. The order can be explicitly set in UpdateConfig, or automatically determined:
// - If the user explicitly set order, respect it
// - Services with port conflicts require stop-first (ports must be freed first)
// - Single-replica services with data volumes default to stop-first (prevents data corruption)
// - Multi-replica services use start-first (concurrent access already happening)
// - All other services default to start-first (minimizes downtime)
func determineUpdateOrder(oldContainer api.ServiceContainer, spec api.ServiceSpec) string {
// User explicitly set order - respect it
if spec.UpdateConfig.Order != "" {
return spec.UpdateConfig.Order
}
// Port conflicts require stop-first
conflictingPorts, err := oldContainer.ConflictingServicePorts(spec.Ports)
if err != nil || len(conflictingPorts) > 0 {
return api.UpdateOrderStopFirst
}
// Single-replica services with data volumes default to stop-first to prevent data corruption.
// Multi-replica services already have concurrent access, so start-first is safe.
if spec.Replicas <= 1 && len(spec.MountedDockerVolumes()) > 0 {
return api.UpdateOrderStopFirst
}
// Default: start-first for minimal downtime
return api.UpdateOrderStartFirst
}
// newEmptyPlan creates a new empty plan for a service deployment with initialised service ID and name. // newEmptyPlan creates a new empty plan for a service deployment with initialised service ID and name.
func newEmptyPlan(svc *api.Service, spec api.ServiceSpec) (Plan, error) { func newEmptyPlan(svc *api.Service, spec api.ServiceSpec) (Plan, error) {
var plan Plan var plan Plan
+425
View File
@@ -0,0 +1,425 @@
package deploy
import (
"testing"
"github.com/docker/docker/api/types/container"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
"github.com/stretchr/testify/assert"
)
func TestDetermineUpdateOrder(t *testing.T) {
tests := []struct {
name string
oldContainer api.ServiceContainer
spec api.ServiceSpec
expected string
}{
{
name: "explicit stop-first order",
oldContainer: api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{Labels: map[string]string{}},
},
},
},
spec: api.ServiceSpec{
UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
},
},
expected: api.UpdateOrderStopFirst,
},
{
name: "explicit start-first order",
oldContainer: api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{Labels: map[string]string{}},
},
},
},
spec: api.ServiceSpec{
UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStartFirst,
},
},
expected: api.UpdateOrderStartFirst,
},
{
name: "explicit start-first overrides volume default",
oldContainer: api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{Labels: map[string]string{}},
},
},
},
spec: api.ServiceSpec{
UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStartFirst,
},
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "data",
},
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
},
},
},
expected: api.UpdateOrderStartFirst,
},
{
name: "single-replica service with volume defaults to stop-first",
oldContainer: api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{Labels: map[string]string{}},
},
},
},
spec: api.ServiceSpec{
Replicas: 1,
Volumes: []api.VolumeSpec{
{
Name: "db-data",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "db-data",
},
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "db-data",
ContainerPath: "/var/lib/postgresql/data",
},
},
},
},
expected: api.UpdateOrderStopFirst,
},
{
name: "multi-replica service with volume defaults to start-first",
oldContainer: api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{Labels: map[string]string{}},
},
},
},
spec: api.ServiceSpec{
Replicas: 3,
Volumes: []api.VolumeSpec{
{
Name: "app-data",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "app-data",
},
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "app-data",
ContainerPath: "/data",
},
},
},
},
expected: api.UpdateOrderStartFirst,
},
{
name: "service with bind mount defaults to start-first",
oldContainer: api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{Labels: map[string]string{}},
},
},
},
spec: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "config",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/etc/app/config",
},
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "config",
ContainerPath: "/config",
},
},
},
},
expected: api.UpdateOrderStartFirst,
},
{
name: "service with tmpfs mount defaults to start-first",
oldContainer: api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{Labels: map[string]string{}},
},
},
},
spec: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "tmp",
Type: api.VolumeTypeTmpfs,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "tmp",
ContainerPath: "/tmp",
},
},
},
},
expected: api.UpdateOrderStartFirst,
},
{
name: "stateless service defaults to start-first",
oldContainer: api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{Labels: map[string]string{}},
},
},
},
spec: api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
},
},
expected: api.UpdateOrderStartFirst,
},
{
name: "port conflict forces stop-first",
oldContainer: api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
Config: &container.Config{
Labels: map[string]string{
api.LabelServicePorts: `[{"container_port":8080,"published_port":8080,"protocol":"tcp","mode":"host"}]`,
},
},
},
},
},
spec: api.ServiceSpec{
Ports: []api.PortSpec{
{
ContainerPort: 8080,
PublishedPort: 8080,
Protocol: "tcp",
Mode: api.PortModeHost,
},
},
},
expected: api.UpdateOrderStopFirst,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := determineUpdateOrder(tt.oldContainer, tt.spec)
assert.Equal(t, tt.expected, result)
})
}
}
func TestReconcileGlobalContainer(t *testing.T) {
container1 := api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
ID: "container-1",
State: &container.State{Running: true},
},
Config: &container.Config{
Labels: map[string]string{
api.LabelServicePorts: "8080:8080/tcp@host",
},
},
},
},
}
container2WithPort9090 := api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
ID: "container-2",
State: &container.State{Running: true},
},
Config: &container.Config{
Labels: map[string]string{
api.LabelServicePorts: "9090:9090/tcp@host",
},
},
},
},
}
container2WithPort3000 := api.ServiceContainer{
Container: api.Container{
InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
ID: "container-2",
State: &container.State{Running: true},
},
Config: &container.Config{
Labels: map[string]string{
api.LabelServicePorts: "3000:3000/tcp@host",
},
},
},
},
}
tests := []struct {
name string
containers []api.MachineServiceContainer
spec api.ServiceSpec
forceRecreate bool
expectedOps []operation.Operation
}{
{
name: "no containers creates new",
containers: nil,
spec: api.ServiceSpec{
Container: api.ContainerSpec{Image: "nginx:latest"},
},
expectedOps: []operation.Operation{
&operation.RunContainerOperation{
ServiceID: "service-1",
MachineID: "machine-1",
},
},
},
{
name: "single running container with port conflict uses replace",
containers: []api.MachineServiceContainer{
{MachineID: "machine-1", Container: container1},
},
spec: api.ServiceSpec{
Container: api.ContainerSpec{Image: "nginx:latest"},
Ports: []api.PortSpec{
{ContainerPort: 8080, PublishedPort: 8080, Protocol: "tcp", Mode: api.PortModeHost},
},
},
expectedOps: []operation.Operation{
&operation.ReplaceContainerOperation{
ServiceID: "service-1",
MachineID: "machine-1",
OldContainer: container1,
Order: api.UpdateOrderStopFirst,
},
},
},
{
name: "multiple running containers with different conflicting ports stops extras before replace",
containers: []api.MachineServiceContainer{
{MachineID: "machine-1", Container: container1},
{MachineID: "machine-1", Container: container2WithPort9090},
},
spec: api.ServiceSpec{
Container: api.ContainerSpec{Image: "nginx:latest"},
Ports: []api.PortSpec{
{ContainerPort: 8080, PublishedPort: 8080, Protocol: "tcp", Mode: api.PortModeHost},
{ContainerPort: 9090, PublishedPort: 9090, Protocol: "tcp", Mode: api.PortModeHost},
},
},
expectedOps: []operation.Operation{
&operation.StopContainerOperation{
ServiceID: "service-1",
ContainerID: "container-2",
MachineID: "machine-1",
},
&operation.ReplaceContainerOperation{
ServiceID: "service-1",
MachineID: "machine-1",
OldContainer: container1,
Order: api.UpdateOrderStopFirst,
},
&operation.RemoveContainerOperation{
MachineID: "machine-1",
Container: container2WithPort9090,
},
},
},
{
name: "multiple running containers but only one has port conflict",
containers: []api.MachineServiceContainer{
{MachineID: "machine-1", Container: container1},
{MachineID: "machine-1", Container: container2WithPort3000},
},
spec: api.ServiceSpec{
Container: api.ContainerSpec{Image: "nginx:latest"},
Ports: []api.PortSpec{
{ContainerPort: 8080, PublishedPort: 8080, Protocol: "tcp", Mode: api.PortModeHost},
},
},
// Container-2 has no conflicting ports, so no StopContainerOperation for it.
expectedOps: []operation.Operation{
&operation.ReplaceContainerOperation{
ServiceID: "service-1",
MachineID: "machine-1",
OldContainer: container1,
Order: api.UpdateOrderStopFirst,
},
&operation.RemoveContainerOperation{
MachineID: "machine-1",
Container: container2WithPort3000,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ops, err := reconcileGlobalContainer(
tt.containers, tt.spec, "service-1", "machine-1", tt.forceRecreate, false,
)
assert.NoError(t, err)
assertOperationsEqual(t, tt.expectedOps, ops)
})
}
}
// assertOperationsEqual compares expected and actual operations, ignoring the Spec field
// which is passed separately to the function and not the focus of these tests.
func assertOperationsEqual(t *testing.T, expected, actual []operation.Operation) {
t.Helper()
opts := cmp.Options{
cmpopts.IgnoreFields(operation.RunContainerOperation{}, "Spec"),
cmpopts.IgnoreFields(operation.ReplaceContainerOperation{}, "Spec"),
cmpopts.IgnoreUnexported(api.Container{}),
}
if diff := cmp.Diff(expected, actual, opts); diff != "" {
t.Errorf("operations mismatch (-expected +actual):\n%s", diff)
}
}
+8 -2
View File
@@ -5,7 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/netip"
"sync" "sync"
"time" "time"
@@ -108,8 +110,7 @@ func verifyCaddyReachable(ctx context.Context, m *pb.MachineInfo) error {
eventID := fmt.Sprintf("Machine %s (%s)", m.Name, publicIP) eventID := fmt.Sprintf("Machine %s (%s)", m.Name, publicIP)
pw.Event(progress.NewEvent(eventID, progress.Working, "Querying")) pw.Event(progress.NewEvent(eventID, progress.Working, "Querying"))
verifyURL := fmt.Sprintf("http://%s%s", publicIP, caddyconfig.VerifyPath) verifyURL := getVerifyURL(publicIP)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil)
if err != nil { if err != nil {
pw.Event(progress.NewEvent(eventID, progress.Error, err.Error())) pw.Event(progress.NewEvent(eventID, progress.Error, err.Error()))
@@ -170,6 +171,11 @@ func verifyCaddyReachable(ctx context.Context, m *pb.MachineInfo) error {
} }
} }
func getVerifyURL(publicIP netip.Addr) string {
httpFormattedIP := net.JoinHostPort(publicIP.String(), "")
return fmt.Sprintf("http://%s%s", httpFormattedIP, caddyconfig.VerifyPath)
}
// unreachable creates a new Unreachable error event. // unreachable creates a new Unreachable error event.
func unreachable(id string) progress.Event { func unreachable(id string) progress.Event {
return progress.NewEvent( return progress.NewEvent(
+30
View File
@@ -0,0 +1,30 @@
package client
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetVerifyURL(t *testing.T) {
tests := map[string]struct {
given netip.Addr
want string
}{
"IPv4": {
given: netip.MustParseAddr("93.184.216.34"),
want: "http://93.184.216.34:/.uncloud-verify",
},
"IPv6": {
given: netip.MustParseAddr("2001:db8::1"),
want: "http://[2001:db8::1]:/.uncloud-verify",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got := getVerifyURL(tt.given)
assert.Equal(t, tt.want, got)
})
}
}
+17 -2
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -363,14 +364,25 @@ func newUnregistryProxy(
return p, nil return p, nil
} }
// isDockerVirtualised checks if Docker is running in a virtualised environment like Docker Desktop on macOS. // isDockerVirtualised checks if Docker is running in a virtualised environment like Docker/Rancher Desktop or Colima.
// On macOS, Docker always requires a VM, so it returns true unless OrbStack is detected (which handles host networking
// natively). On other platforms, it checks for known virtualised Docker hostnames.
func isDockerVirtualised(ctx context.Context, dockerCli *docker.Client) (bool, error) { func isDockerVirtualised(ctx context.Context, dockerCli *docker.Client) (bool, error) {
info, err := dockerCli.Info(ctx) info, err := dockerCli.Info(ctx)
if err != nil { if err != nil {
return false, fmt.Errorf("get Docker info: %w", err) return false, fmt.Errorf("get Docker info: %w", err)
} }
virtualisedHostnames := []string{"docker-desktop", "colima"} // On macOS, Docker always runs in a VM. OrbStack is the only known exception that doesn't need a proxy.
if runtime.GOOS == "darwin" {
if info.Name == "orbstack" {
return false, nil
}
return true, nil
}
// On other platforms, check for known virtualised Docker environments.
virtualisedHostnames := []string{"docker-desktop", "rancher-desktop", "colima"}
for _, name := range virtualisedHostnames { for _, name := range virtualisedHostnames {
if strings.Contains(strings.ToLower(info.Name), name) { if strings.Contains(strings.ToLower(info.Name), name) {
return true, nil return true, nil
@@ -406,6 +418,9 @@ func runDockerVMProxyContainer(ctx context.Context, dockerCli *docker.Client, ta
ExposedPorts: nat.PortSet{ ExposedPorts: nat.PortSet{
containerPort: {}, containerPort: {},
}, },
Labels: map[string]string{
api.LabelManaged: "",
},
} }
// Get an available port on localhost to bind the container port to by creating a temporary listener and closing it. // Get an available port on localhost to bind the container port to by creating a temporary listener and closing it.
+1 -1
View File
@@ -338,7 +338,7 @@ if [ "$DOCKER_ALREADY_INSTALLED" = "true" ] && [ "$CONTAINERD_IMAGE_STORE_ENABLE
echo "" echo ""
warning "Docker was already installed on the machine but it doesn't use the containerd" warning "Docker was already installed on the machine but it doesn't use the containerd"
warning "image store. Uncloud works best with the containerd image store enabled in Docker." warning "image store. Uncloud works best with the containerd image store enabled in Docker."
warning "It allows Docker to directly use the images stores in containerd (pushed with" warning "It allows Docker to directly use the images stored in containerd (pushed with"
warning "'uc image push') without duplicating them in Docker. This saves disk space and" warning "'uc image push') without duplicating them in Docker. This saves disk space and"
warning "makes image management more efficient." warning "makes image management more efficient."
echo "" echo ""
+30
View File
@@ -58,6 +58,36 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api
assert.Contains(t, ctr.Config.Env, env) assert.Contains(t, ctr.Config.Env, env)
} }
// Healthcheck can only be compared if set in the spec. Otherwise, the container inherits it from the image.
if spec.Container.Healthcheck != nil {
hc := spec.Container.Healthcheck
require.NotNil(t, ctr.Config.Healthcheck)
if hc.Disable {
assert.Equal(t, []string{"NONE"}, ctr.Config.Healthcheck.Test)
} else {
// Only compare fields that are explicitly set in the spec as unset fields inherit their values
// from the image.
if hc.Test != nil {
assert.EqualValues(t, hc.Test, ctr.Config.Healthcheck.Test)
}
if hc.Interval != 0 {
assert.Equal(t, hc.Interval, ctr.Config.Healthcheck.Interval)
}
if hc.Timeout != 0 {
assert.Equal(t, hc.Timeout, ctr.Config.Healthcheck.Timeout)
}
if hc.StartPeriod != 0 {
assert.Equal(t, hc.StartPeriod, ctr.Config.Healthcheck.StartPeriod)
}
if hc.StartInterval != 0 {
assert.Equal(t, hc.StartInterval, ctr.Config.Healthcheck.StartInterval)
}
if hc.Retries != 0 {
assert.Equal(t, int(hc.Retries), ctr.Config.Healthcheck.Retries)
}
}
}
assert.Equal(t, spec.Container.Image, ctr.Config.Image) assert.Equal(t, spec.Container.Image, ctr.Config.Image)
assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init) assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init)
assert.True(t, strings.HasPrefix(ctr.Name, spec.Name+"-")) assert.True(t, strings.HasPrefix(ctr.Name, spec.Name+"-"))
+41
View File
@@ -558,4 +558,45 @@ volumes:
assert.Len(t, plan.Operations, 2, "Expected 1 volume creation and 1 service to deploy") assert.Len(t, plan.Operations, 2, "Expected 1 volume creation and 1 service to deploy")
}) })
t.Run("global service auto-creates volumes on all machines", func(t *testing.T) {
t.Parallel()
serviceName := "test-compose-global-volume"
volumeName := serviceName
t.Cleanup(func() {
removeServices(t, cli, serviceName)
for _, machine := range c.Machines {
_ = cli.RemoveVolume(ctx, machine.Name, volumeName, false)
}
})
project, err := compose.LoadProject(ctx, []string{"fixtures/compose-global-volume.yaml"})
require.NoError(t, err)
deployment, err := compose.NewDeployment(ctx, cli, project)
require.NoError(t, err)
err = deployment.Run(ctx)
require.NoError(t, err, "Global deployment should auto-create volumes on all machines")
// Verify volumes were created on all machines.
volumes, err := cli.ListVolumes(ctx, &api.VolumeFilter{Names: []string{volumeName}})
require.NoError(t, err)
assert.Len(t, volumes, len(c.Machines), "Volume should be created on all machines")
// Verify containers are running on all machines.
svc, err := cli.InspectService(ctx, serviceName)
require.NoError(t, err)
assert.Equal(t, api.ServiceModeGlobal, svc.Mode)
assert.Len(t, svc.Containers, len(c.Machines), "Container should be running on all machines")
machines := serviceMachines(svc)
expectedMachines := make([]string, len(c.Machines))
for i, m := range c.Machines {
expectedMachines[i] = m.ID
}
assert.ElementsMatch(t, machines.ToSlice(), expectedMachines,
"Containers should be distributed across all machines")
})
} }
@@ -0,0 +1,10 @@
services:
test-compose-global-volume:
image: portainer/pause:latest
deploy:
mode: global
volumes:
- test-compose-global-volume:/data
volumes:
test-compose-global-volume:
+15
View File
@@ -0,0 +1,15 @@
package e2e
import (
"os"
"testing"
"github.com/psviderski/uncloud/pkg/api"
)
func TestMain(m *testing.M) {
// Disable the default health monitor period to speed up tests. Tests that need a non-zero monitor period
// should set it explicitly via UpdateConfig.MonitorPeriod in their service spec.
api.DefaultHealthMonitorPeriod = 0
os.Exit(m.Run())
}
+227 -4
View File
@@ -56,11 +56,16 @@ func TestDeployment(t *testing.T) {
require.ErrorIs(t, err, api.ErrNotFound) require.ErrorIs(t, err, api.ErrNotFound)
}) })
// Explicit short period as the default 5s is disabled in tests for faster test execution.
monitorPeriod := 1 * time.Second
spec := api.ServiceSpec{ spec := api.ServiceSpec{
Mode: api.ServiceModeGlobal, Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{ Container: api.ContainerSpec{
Image: "portainer/pause:latest", Image: "portainer/pause:latest",
}, },
UpdateConfig: api.UpdateConfig{
MonitorPeriod: &monitorPeriod,
},
} }
deployment := cli.NewDeployment(spec, nil) deployment := cli.NewDeployment(spec, nil)
@@ -73,7 +78,9 @@ func TestDeployment(t *testing.T) {
assert.NotEmpty(t, plan.ServiceName) assert.NotEmpty(t, plan.ServiceName)
assert.Len(t, plan.SequenceOperation.Operations, 3) // 3 run assert.Len(t, plan.SequenceOperation.Operations, 3) // 3 run
start := time.Now()
runPlan, err := deployment.Run(ctx) runPlan, err := deployment.Run(ctx)
duration := time.Since(start)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, plan, runPlan) assert.Equal(t, plan, runPlan)
@@ -88,6 +95,13 @@ func TestDeployment(t *testing.T) {
machines := serviceMachines(svc) machines := serviceMachines(svc)
assert.Len(t, machines.ToSlice(), 3, "Expected 1 container on each machine") assert.Len(t, machines.ToSlice(), 3, "Expected 1 container on each machine")
for _, ctr := range svc.Containers {
assert.True(t, ctr.Container.Healthy(), "Expected deployed containers to be healthy")
}
assert.True(t, duration >= 3*monitorPeriod,
"Expected deployment to wait for at least the health monitor period for each container "+
"before checking health")
// Deploy a published port. // Deploy a published port.
initialContainers := serviceContainerIDs(svc) initialContainers := serviceContainerIDs(svc)
@@ -105,14 +119,19 @@ func TestDeployment(t *testing.T) {
Mode: api.PortModeHost, Mode: api.PortModeHost,
}, },
}, },
UpdateConfig: api.UpdateConfig{
MonitorPeriod: &monitorPeriod,
},
} }
deployment = cli.NewDeployment(specWithPort, nil) deployment = cli.NewDeployment(specWithPort, nil)
plan, err = deployment.Plan(ctx) plan, err = deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.SequenceOperation.Operations, 6) // 3 run + 3 remove assert.Len(t, plan.SequenceOperation.Operations, 3) // 3 replace
start = time.Now()
_, err = deployment.Run(ctx) _, err = deployment.Run(ctx)
duration = time.Since(start)
require.NoError(t, err) require.NoError(t, err)
svc, err = cli.InspectService(ctx, name) svc, err = cli.InspectService(ctx, name)
@@ -126,6 +145,13 @@ func TestDeployment(t *testing.T) {
assert.Empty(t, initialContainers.Intersect(containers).ToSlice(), assert.Empty(t, initialContainers.Intersect(containers).ToSlice(),
"All existing containers should be replaced") "All existing containers should be replaced")
for _, ctr := range svc.Containers {
assert.True(t, ctr.Container.Healthy(), "Expected redeployed containers to be healthy")
}
assert.True(t, duration >= 3*monitorPeriod,
"Expected deployment to wait for at least the health monitor period for each container "+
"before checking health")
// Deploy the same conflicting port but with container spec changes // Deploy the same conflicting port but with container spec changes
initialContainers = containers initialContainers = containers
@@ -150,7 +176,7 @@ func TestDeployment(t *testing.T) {
plan, err = deployment.Plan(ctx) plan, err = deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.SequenceOperation.Operations, 9) // 3 stop + 3 run + 3 remove assert.Len(t, plan.SequenceOperation.Operations, 3) // 3 replace (stop-first due to port conflicts)
_, err = deployment.Run(ctx) _, err = deployment.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -500,6 +526,8 @@ myapp.example.com {
}) })
// 1. Create a basic replicated service with 2 replicas. // 1. Create a basic replicated service with 2 replicas.
// Explicit short period as the default 5s is disabled in tests for faster test execution.
monitorPeriod := 1 * time.Second
spec := api.ServiceSpec{ spec := api.ServiceSpec{
Name: name, Name: name,
Mode: api.ServiceModeReplicated, Mode: api.ServiceModeReplicated,
@@ -507,6 +535,9 @@ myapp.example.com {
Image: "portainer/pause:latest", Image: "portainer/pause:latest",
}, },
Replicas: 2, Replicas: 2,
UpdateConfig: api.UpdateConfig{
MonitorPeriod: &monitorPeriod,
},
} }
deployment := cli.NewDeployment(spec, nil) deployment := cli.NewDeployment(spec, nil)
@@ -519,7 +550,9 @@ myapp.example.com {
assert.Equal(t, name, plan.ServiceName) assert.Equal(t, name, plan.ServiceName)
assert.Len(t, plan.SequenceOperation.Operations, 2) // 2 run operations for 2 replicas assert.Len(t, plan.SequenceOperation.Operations, 2) // 2 run operations for 2 replicas
start := time.Now()
runPlan, err := deployment.Run(ctx) runPlan, err := deployment.Run(ctx)
duration := time.Since(start)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, plan, runPlan) assert.Equal(t, plan, runPlan)
@@ -533,6 +566,13 @@ myapp.example.com {
assert.Len(t, initialMachines.ToSlice(), 2, "Expected 2 containers on 2 different machines") assert.Len(t, initialMachines.ToSlice(), 2, "Expected 2 containers on 2 different machines")
initialContainers := serviceContainerIDs(svc) initialContainers := serviceContainerIDs(svc)
for _, ctr := range svc.Containers {
assert.True(t, ctr.Container.Healthy(), "Expected deployed containers to be healthy")
}
assert.True(t, duration >= 2*monitorPeriod,
"Expected deployment to wait for at least the health monitor period for each container "+
"before checking health")
// 2. Update the service with a new configuration. // 2. Update the service with a new configuration.
init := true init := true
updatedSpec := spec updatedSpec := spec
@@ -541,9 +581,11 @@ myapp.example.com {
plan, err = deployment.Plan(ctx) plan, err = deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 4, "Expected 2 run + 2 remove operations") assert.Len(t, plan.Operations, 2, "Expected 2 replace operations")
start = time.Now()
_, err = deployment.Run(ctx) _, err = deployment.Run(ctx)
duration = time.Since(start)
require.NoError(t, err) require.NoError(t, err)
svc, err = cli.InspectService(ctx, name) svc, err = cli.InspectService(ctx, name)
@@ -558,6 +600,13 @@ myapp.example.com {
assert.Empty(t, initialContainers.Intersect(containers).ToSlice(), assert.Empty(t, initialContainers.Intersect(containers).ToSlice(),
"All existing containers should be replaced") "All existing containers should be replaced")
for _, ctr := range svc.Containers {
assert.True(t, ctr.Container.Healthy(), "Expected deployed containers to be healthy")
}
assert.True(t, duration >= 2*monitorPeriod,
"Expected deployment to wait for at least the health monitor period for each container "+
"before checking health")
// 3. Scale to 3 replicas. // 3. Scale to 3 replicas.
initialMachines = machines initialMachines = machines
initialContainers = containers // Reset container tracking. initialContainers = containers // Reset container tracking.
@@ -593,7 +642,7 @@ myapp.example.com {
plan, err = deployment.Plan(ctx) plan, err = deployment.Plan(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, plan.Operations, 8, "Expected 5 run + 3 remove operations") assert.Len(t, plan.Operations, 5, "Expected 3 replace + 2 run operations")
_, err = deployment.Run(ctx) _, err = deployment.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -1213,6 +1262,9 @@ myapp.example.com {
Container: api.ContainerSpec{ Container: api.ContainerSpec{
Image: uniqueImage, Image: uniqueImage,
}, },
Placement: api.Placement{
Machines: []string{c.Machines[0].Name, c.Machines[1].Name},
},
Replicas: 2, Replicas: 2,
} }
@@ -1264,6 +1316,168 @@ myapp.example.com {
} }
}) })
t.Run("healthcheck becomes healthy", func(t *testing.T) {
t.Parallel()
name := "test-health-ok"
t.Cleanup(func() {
err := cli.RemoveService(ctx, name)
if !errors.Is(err, api.ErrNotFound) {
require.NoError(t, err)
}
})
monitorPeriod := 60 * time.Second
spec := api.ServiceSpec{
Name: name,
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "busybox:1.37.0-musl",
Command: []string{"sh", "-c", "sleep 3600"},
Healthcheck: &api.HealthcheckSpec{
Test: []string{"CMD-SHELL", "exit 0"},
Interval: 1 * time.Second,
Retries: 2,
},
},
UpdateConfig: api.UpdateConfig{
MonitorPeriod: &monitorPeriod,
},
}
deployment := cli.NewDeployment(spec, nil)
start := time.Now()
_, err := deployment.Run(ctx)
duration := time.Since(start)
require.NoError(t, err)
svc, err := cli.InspectService(ctx, name)
require.NoError(t, err)
assertServiceMatchesSpec(t, svc, spec)
assert.True(t, svc.Containers[0].Container.Healthy())
assert.True(t, duration >= 1*time.Second,
"Deployment should wait for at least one health check interval before marking container as healthy")
assert.True(t, duration < monitorPeriod,
"Deployment should mark container as healthy after first successful health check "+
"and not wait for the entire monitor period")
})
t.Run("healthcheck becomes unhealthy", func(t *testing.T) {
t.Parallel()
name := "test-health-fail"
t.Cleanup(func() {
err := cli.RemoveService(ctx, name)
if !errors.Is(err, api.ErrNotFound) {
require.NoError(t, err)
}
})
monitorPeriod := 1 * time.Second
spec := api.ServiceSpec{
Name: name,
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "busybox:1.37.0-musl",
Command: []string{"sh", "-c", "sleep 3600"},
Healthcheck: &api.HealthcheckSpec{
Test: []string{"CMD-SHELL", "exit 1"},
Interval: 1 * time.Second,
Retries: 2,
},
},
UpdateConfig: api.UpdateConfig{
MonitorPeriod: &monitorPeriod,
},
}
deployment := cli.NewDeployment(spec, nil)
start := time.Now()
_, err := deployment.Run(ctx)
duration := time.Since(start)
require.Error(t, err)
assert.ErrorContains(t, err, "unhealthy")
assert.True(t, duration >= monitorPeriod,
"Deployment should wait for at least the monitor period before checking health status")
})
t.Run("container crashes on startup with healthcheck", func(t *testing.T) {
t.Parallel()
name := "test-crash-startup-healthcheck"
t.Cleanup(func() {
err := cli.RemoveService(ctx, name)
if !errors.Is(err, api.ErrNotFound) {
require.NoError(t, err)
}
})
monitorPeriod := 1 * time.Second
spec := api.ServiceSpec{
Name: name,
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "busybox:1.37.0-musl",
Command: []string{"false"},
Healthcheck: &api.HealthcheckSpec{
Test: []string{"CMD-SHELL", "exit 0"},
Interval: 1 * time.Second,
Retries: 2,
},
},
UpdateConfig: api.UpdateConfig{
MonitorPeriod: &monitorPeriod,
},
}
deployment := cli.NewDeployment(spec, nil)
start := time.Now()
_, err := deployment.Run(ctx)
duration := time.Since(start)
require.Error(t, err)
assert.ErrorContains(t, err, "restarting")
assert.True(t, duration >= monitorPeriod,
"Deployment should wait for at least the monitor period before marking container as unhealthy")
})
t.Run("container crashes on startup without healthcheck", func(t *testing.T) {
t.Parallel()
name := "test-crash-startup-no-healthcheck"
t.Cleanup(func() {
err := cli.RemoveService(ctx, name)
if !errors.Is(err, api.ErrNotFound) {
require.NoError(t, err)
}
})
monitorPeriod := 1 * time.Second
spec := api.ServiceSpec{
Name: name,
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Image: "busybox:1.37.0-musl",
Command: []string{"false"},
},
UpdateConfig: api.UpdateConfig{
MonitorPeriod: &monitorPeriod,
},
}
deployment := cli.NewDeployment(spec, nil)
start := time.Now()
_, err := deployment.Run(ctx)
duration := time.Since(start)
require.Error(t, err)
assert.ErrorContains(t, err, "restarting")
assert.True(t, duration >= monitorPeriod,
"Deployment should wait for at least the monitor period before marking container as unhealthy")
})
// TODO: test deployments with unreachable machines. See https://github.com/psviderski/uncloud/issues/29. // TODO: test deployments with unreachable machines. See https://github.com/psviderski/uncloud/issues/29.
} }
@@ -1315,6 +1529,7 @@ func TestServiceLifecycle(t *testing.T) {
Name: "container-spec-full", Name: "container-spec-full",
Mode: api.ServiceModeGlobal, Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{ Container: api.ContainerSpec{
// TODO: Add the latest implemented fields to this spec and update assertContainerMatchesSpec.
Command: []string{"sleep", "infinity"}, Command: []string{"sleep", "infinity"},
// Extra slashes is not a typo, it changes the spec but Linux ignores them and uses the default /pause. // Extra slashes is not a typo, it changes the spec but Linux ignores them and uses the default /pause.
Entrypoint: []string{"///pause"}, Entrypoint: []string{"///pause"},
@@ -1324,6 +1539,14 @@ func TestServiceLifecycle(t *testing.T) {
"BOOL": "true", "BOOL": "true",
"": "ignored", "": "ignored",
}, },
Healthcheck: &api.HealthcheckSpec{
Test: []string{"CMD-SHELL", "exit 0"},
Interval: 1*time.Minute + 30*time.Second,
Timeout: 10 * time.Second,
Retries: 5,
StartPeriod: 15 * time.Second,
StartInterval: 2 * time.Second,
},
Image: "portainer/pause:latest", Image: "portainer/pause:latest",
Init: &init, Init: &init,
LogDriver: &api.LogDriver{ LogDriver: &api.LogDriver{
+3
View File
@@ -20,3 +20,6 @@ services:
log log
} }
scale: 2 scale: 2
x-machines:
- uc-prod-us2
- uc-prod-ap1
@@ -78,7 +78,7 @@ See [Push local images to cluster machines](1-deploy-app.md#push-local-images-to
## See also ## See also
- [Deploy an app](1-deploy-app.md): Deploy from source code or prebuilt images - [Deploy an app](1-deploy-app.md): Build and deploy from source code or prebuilt images
- [Deploy a global service](3-deploy-global-services.md): Deploy one service replica on each cluster machine - [Deploy a global service](3-deploy-global-services.md): Deploy one service replica on each cluster machine
- [Compose support matrix](../../8-compose-file-reference/1-support-matrix.md): Supported Compose features and Uncloud - [Compose support matrix](../../8-compose-file-reference/1-support-matrix.md): Supported Compose features and Uncloud
extensions extensions
@@ -0,0 +1,173 @@
# Rolling deployments
How `uc deploy` updates your services without downtime and automatically rolls back on failure.
Uncloud uses a rolling deployment to update your service by replacing its containers **one at a time**. Before moving on
to the next container, Uncloud waits for the new one to pass [health monitoring](#health-monitoring). If it fails to
become healthy, Uncloud stops the deployment and rolls back that container to the old one. This keeps your service
available throughout the update.
For a service with three replicas and the default `start-first` [update order](#update-order), the deployment looks like
this:
1. Start new container #1, wait until healthy
2. Stop and remove old container #1
3. Start new container #2, wait until healthy
4. Stop and remove old container #2
5. Start new container #3, wait until healthy
6. Stop and remove old container #3
At every step, at least three containers are serving traffic.
## Update order
The **update order** controls whether Uncloud starts the new container before or after stopping the old one.
| Order | What happens | Best for |
|---------------|-----------------------------------------------------------------------------|-------------------------------------|
| `start-first` | Start new container, then stop old<br/>(running containers briefly overlap) | Stateless services (web apps, APIs) |
| `stop-first` | Stop old container, then start new | Stateful services (databases) |
The default is `start-first` so there's **no downtime**. But it automatically switches to `stop-first` in two cases:
- **Host port conflicts**: the old container must free the port before the new one can bind to it.
- **Single-replica service with a volume**: two containers simultaneously writing to the same volume can
**corrupt data**, so Uncloud stops the old container first to prevent this.
`stop-first` can cause a **brief downtime** while the old container stops and the new one starts in these cases. The
deployment plan printed by `uc deploy` indicates which containers will be replaced with `stop-first`.
A multiple-replica service with a volume doesn't automatically switch to `stop-first` as Uncloud assumes that the
concurrent access is desired and safe. Host path and tmpfs mounts don't trigger the switch either.
### Override update order
You can override the update order with `deploy.update_config.order`:
```yaml title="compose.yaml"
services:
app:
image: myapp
volumes:
- data:/data
deploy:
update_config:
order: start-first
volumes:
data:
```
This single-replica service uses a volume, so Uncloud would normally use `stop-first`. Setting `order: start-first`
overrides that.
This is useful if your app handles concurrent access to data safely and you want to avoid downtime. For example, the app
uses an SQLite database in WAL mode on the volume.
## Health monitoring
After starting each new container, Uncloud **monitors** it for failures for **5 seconds** to make sure it keeps running
and not crashing. If it keeps restarting after this period, the deployment fails and Uncloud
[rolls back](#rollback-on-failure) that container to the old one.
This is a safeguard to prevent you from deploying broken code or misconfiguration that would cause downtime. 5 seconds
is typically enough for a process in a container to initialise all its dependencies and start.
You can change the monitoring period for a service with `deploy.update_config.monitor`. For example, increase it if your
app takes longer to start or if you want to give it more time to recover from transient errors on startup.
Set it to `0s` to skip monitoring entirely if you are confident the new containers will start correctly and want to
speed up the deployment.
```yaml title="compose.yaml"
services:
app:
image: myapp
deploy:
update_config:
# Specified as duration: 500ms, 20s, 1m30s, 0s (skip)
monitor: 10s
```
You can also change the default monitoring period (`5s`) for all services globally with an environment variable
`UNCLOUD_HEALTH_MONITOR_PERIOD`:
```shell
export UNCLOUD_HEALTH_MONITOR_PERIOD=10s
# or skip monitoring for all services
export UNCLOUD_HEALTH_MONITOR_PERIOD=0s
```
`deploy.update_config.monitor` overrides the global default for each service.
### Health checks
If your container has a [`healthcheck`](https://github.com/compose-spec/compose-spec/blob/main/spec.md#healthcheck)
configured, Uncloud also checks its health status during and after the monitoring period.
If the container becomes `healthy` before the monitoring period ends, the deployment succeeds early and moves on to the
next container. If the container is `unhealthy` after the monitoring period, Uncloud
[rolls it back](#rollback-on-failure) and fails the deployment. Transient `unhealthy` states during the monitoring
period are tolerated to give the container time to recover from startup issues.
To make deployments **safer** and **faster**, it's recommended to configure a health check that can quickly notify
Uncloud when containers start successfully and become ready to serve traffic. You can configure it with
[`healthcheck`](https://github.com/compose-spec/compose-spec/blob/main/spec.md#healthcheck) in your Compose file or
[`HEALTHCHECK`](https://docs.docker.com/reference/dockerfile#healthcheck) in your image `Dockerfile`:
```yaml title="compose.yaml"
services:
app:
image: myapp
healthcheck:
test: curl -f http://localhost:8000/health
interval: 5s
retries: 3
start_period: 10s
start_interval: 1s
```
:::info important
If a health check fails after the deployment, Uncloud automatically removes the unhealthy container from the
[Caddy](../../3-concepts/1-ingress/1-overview.md) configuration to prevent routing traffic to that container. But it
doesn't automatically restart or roll it back.
Uncloud automatically adds it back to Caddy when it recovers and becomes healthy again. You can inspect the health
status of your containers with [`uc ps`](../../9-cli-reference/uc_ps.md) or
[`uc inspect`](../../9-cli-reference/uc_inspect.md) and check their logs with
[`uc logs`](../../9-cli-reference/uc_logs.md).
:::
### Skip health monitoring
To skip health monitoring for **faster emergency deployments**, use `uc deploy --skip-health`.
:::warning
`--skip-health` won't detect containers that crash on startup or become unhealthy so won't roll them back or stop the
deployment. Use this only for emergency deployments when you are confident the new containers will start correctly.
:::
## Rollback on failure
If a new container fails health monitoring during a deployment, Uncloud stops it but keeps it around so you can inspect
its state and logs. For `stop-first` order, Uncloud also restarts the old container. The deployment then stops and the
remaining containers are left untouched.
For example, if the first container in a rolling update succeeds but the second one fails, the first replacement stays
in place.
## Retry after failure
You can retry the deployment by running `uc deploy` again. Uncloud will skip the successfully deployed containers if the
configuration hasn't changed and only redeploy the remaining ones.
## See also
- [Deploy an app](1-deploy-app.md): Build and deploy from source code or pre-built images
- [Compose support matrix](../../8-compose-file-reference/1-support-matrix.md): Supported Compose features and Uncloud
extensions
@@ -3,66 +3,68 @@
Uncloud supports a subset of the [Compose specification](https://compose-spec.io/) with some extensions and limitations. Uncloud supports a subset of the [Compose specification](https://compose-spec.io/) with some extensions and limitations.
The following table shows the support status for main Compose features: The following table shows the support status for main Compose features:
| Feature | Support Status | Notes | | Feature | Support Status | Notes |
|--------------------|--------------------|------------------------------------------------------------------------------------------------| |--------------------|--------------------|----------------------------------------------------------------------------------------------------------------|
| **Services** | | | | **Services** | | |
| `build` | ✅ Supported | Build context and Dockerfile | | `build` | ✅ Supported | Build context and Dockerfile |
| `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) | | `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) |
| `cap_drop` | ✅ Supported | Which kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) to drop | | `cap_drop` | ✅ Supported | Which kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) to drop |
| `command` | ✅ Supported | Override container command | | `command` | ✅ Supported | Override container command |
| `configs` | ✅ Supported | File-based and inline configs | | `configs` | ✅ Supported | File-based and inline configs |
| `cpus` | ✅ Supported | CPU limit | | `cpus` | ✅ Supported | CPU limit |
| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked | | `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked |
| `dns` | ❌ Not supported | Built-in service discovery | | `devices` | ✅ Supported | Device mappings |
| `dns_search` | ❌ Not supported | Built-in service discovery | | `dns` | ❌ Not supported | Built-in service discovery |
| `entrypoint` | ✅ Supported | Override container entrypoint | | `dns_search` | ❌ Not supported | Built-in service discovery |
| `env_file` | ✅ Supported | Environment file | | `entrypoint` | ✅ Supported | Override container entrypoint |
| `environment` | ✅ Supported | Environment variables | | `env_file` | ✅ Supported | Environment file |
| `gpus` | ✅ Supported | GPU device access | | `environment` | ✅ Supported | Environment variables |
| `image` | ✅ Supported | Container image specification | | `gpus` | ✅ Supported | GPU device access |
| `init` | ✅ Supported | Run init process in container | | `healthcheck` | ✅ Supported | Health check configuration |
| `labels` | ❌ Not supported | | | `image` | ✅ Supported | Container image specification |
| `links` | ❌ Not supported | Use service names for communication | | `init` | ✅ Supported | Run init process in container |
| `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver | | `labels` | ❌ Not supported | |
| `mem_limit` | ✅ Supported | Memory limit | | `links` | ❌ Not supported | Use service names for communication |
| `mem_reservation` | ✅ Supported | Memory reservation | | `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver |
| `mem_swappiness` | ❌ Not supported | | | `mem_limit` | ✅ Supported | Memory limit |
| `memswap_limit` | ❌ Not supported | | | `mem_reservation` | ✅ Supported | Memory reservation |
| `networks` | ❌ Not supported | All containers share cluster network | | `mem_swappiness` | ❌ Not supported | |
| `ports` | ⚠️ Limited | `mode: host` only, use `x-ports` for HTTP/HTTPS | | `memswap_limit` | ❌ Not supported | |
| `privileged` | ✅ Supported | Run containers in privileged mode | | `networks` | ❌ Not supported | All containers share cluster network |
| `pull_policy` | ✅ Supported | `always`, `missing`, `never` | | `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](#x-ports) for HTTP/HTTPS |
| `secrets` | ❌ Not supported | Use configs or environment variables | | `privileged` | ✅ Supported | Run containers in privileged mode |
| `security_opt` | ❌ Not supported | | | `pull_policy` | ✅ Supported | `always`, `missing`, `never` |
| `storage_opt` | ❌ Not supported | | | `secrets` | ❌ Not supported | Use configs or environment variables |
| `sysctls` | ✅ Supported | Namespaced kernel parameters | | `security_opt` | ❌ Not supported | |
| `user` | ✅ Supported | Set container user | | `storage_opt` | ❌ Not supported | |
| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs | | `sysctls` | ✅ Supported | Namespaced kernel parameters |
| **Deploy** | | | | `user` | ✅ Supported | Set container user |
| `labels` | ❌ Not supported | | | `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs |
| `mode` | ✅ Supported | Either `global` or `replicated` | | **Deploy** | | |
| `placement` | ❌ Not supported | Use `x-machines` extension | | `labels` | ❌ Not supported | |
| `replicas` | ✅ Supported | Number of container replicas | | `mode` | ✅ Supported | Either `global` or `replicated` |
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations | | `placement` | ❌ Not supported | Use [`x-machines`](#x-machines) extension |
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` | | `replicas` | ✅ Supported | Number of container replicas |
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) | | `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
| `update_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) | | `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |
| **Volumes** | | | | `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
| Named volumes | ✅ Supported | Docker volumes | | `update_config` | ⚠️ Limited | `order` and `monitor` supported. See [rolling deployments](../4-guides/1-deployments/4-rolling-deployments.md) |
| Bind mounts | ✅ Supported | Host path binding | | **Volumes** | | |
| Tmpfs mounts | ✅ Supported | In-memory filesystems | | Named volumes | ✅ Supported | Docker volumes |
| Volume labels | ✅ Supported | Custom labels | | Bind mounts | ✅ Supported | Host path binding |
| External volumes | ✅ Supported | Must exist before deployment | | Tmpfs mounts | ✅ Supported | In-memory filesystems |
| Volume drivers | ⚠️ Limited | Local driver only | | Volume labels | ✅ Supported | Custom labels |
| **Configs** | | | | External volumes | ✅ Supported | Must exist before deployment |
| File-based configs | ✅ Supported | Read from file | | Volume drivers | ⚠️ Limited | Local driver only |
| Inline configs | ✅ Supported | Defined in compose file | | **Configs** | | |
| External configs | ❌ Not supported | Not supported | | File-based configs | ✅ Supported | Read from file |
| Short syntax | ❌ Not supported | Use long syntax only | | Inline configs | ✅ Supported | Defined in compose file |
| **Extensions** | | | | External configs | ❌ Not supported | Not supported |
| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration | | Short syntax | ❌ Not supported | Use long syntax only |
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints | | **Extensions** | | |
| `x-ports` | ✅ Uncloud-specific | Service port publishing | | `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration |
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints |
| `x-ports` | ✅ Uncloud-specific | Service port publishing |
### Legend ### Legend
@@ -14,8 +14,8 @@ Git repository state. You can customise the image name and tag format for the bu
If you **don't specify** an `image` attribute for a service with a `build` section, Uncloud uses the following Go If you **don't specify** an `image` attribute for a service with a `build` section, Uncloud uses the following Go
template for tagging the built image: template for tagging the built image:
```go ```
{{.Project}}/{{.Service}}:{{if.Git.IsRepo}}{{gitdate "2006-01-02-150405"}}.{{gitsha 7}}{{if.Git.IsDirty}}.dirty{{end}}{{else}}{{date "2006-01-02-150405"}}{{end}} {{.Project}}/{{.Service}}:{{if .Git.IsRepo}}{{gitdate "2006-01-02-150405"}}.{{gitsha 7}}{{if .Git.IsDirty}}.dirty{{end}}{{else}}{{date "2006-01-02-150405"}}{{end}}
``` ```
```yaml title="compose.yaml" ```yaml title="compose.yaml"
@@ -92,8 +92,7 @@ image: myapp:{{date "20060102-150405" "Local"}} # → myapp:20251031-220651
### Date format reference ### Date format reference
Go uses a reference time (`Mon Jan 2 15:04:05 MST 2006`) for formatting. Replace reference components with desired Go uses a reference time `Mon Jan 2 15:04:05 MST 2006` for formatting. Replace reference components with desired format:
format:
| Component | Reference | Example | | Component | Reference | Example |
|-----------|-----------|-------------| |-----------|-----------|-------------|
@@ -18,6 +18,8 @@ uc deploy [FLAGS] [SERVICE...] [flags]
--no-cache Do not use cache when building images. --no-cache Do not use cache when building images.
-p, --profile strings One or more Compose profiles to enable. -p, --profile strings One or more Compose profiles to enable.
--recreate Recreate containers even if their configuration and image haven't changed. --recreate Recreate containers even if their configuration and image haven't changed.
--skip-health Skip the monitoring period and health checks after starting new containers. Useful for faster emergency deployments.
Warning: This may cause downtime if new containers fail to start properly.
-y, --yes Auto-confirm deployment plan. Should be explicitly set when running non-interactively, -y, --yes Auto-confirm deployment plan. Should be explicitly set when running non-interactively,
e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM] e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]
``` ```
+15 -39
View File
@@ -64,18 +64,22 @@
<!-- Left side navigation --> <!-- Left side navigation -->
<ul class="flex items-center"> <ul class="flex items-center">
<li> <li>
<a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-3 lg:px-5 py-2 transition" <a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-2 sm:px-5 py-2 transition"
href="/docs">Docs</a> href="/docs">Docs</a>
</li> </li>
<li> <li>
<a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-3 lg:px-5 py-2 transition" <a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-2 sm:px-5 py-2 transition"
href="/blog">Blog</a> href="/blog">Blog</a>
</li> </li>
<li>
<a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-2 sm:px-5 py-2 transition"
href="https://psviderski.substack.com/" target="_blank">Newsletter</a>
</li>
</ul> </ul>
<!-- Right side navigation --> <!-- Right side navigation -->
<ul class="flex grow justify-end flex-wrap items-center"> <ul class="flex grow justify-end items-center shrink-0">
<li> <li>
<a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-2 sm:px-3 lg:px-5 py-2 flex items-center transition" <a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-2 sm:px-5 py-2 flex items-center transition"
href="https://discord.gg/eR35KQJhPu"> href="https://discord.gg/eR35KQJhPu">
<svg class="w-4 h-4 sm:mr-1 fill-current" xmlns="http://www.w3.org/2000/svg" <svg class="w-4 h-4 sm:mr-1 fill-current" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
@@ -85,7 +89,7 @@
</a> </a>
</li> </li>
<li> <li>
<a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-2 sm:px-3 lg:px-5 py-2 flex items-center transition" <a class="text-sm font-medium text-zinc-500 hover:text-zinc-900 px-1 sm:px-5 py-2 flex items-center transition"
href="https://github.com/psviderski/uncloud"> href="https://github.com/psviderski/uncloud">
<svg class="w-4 h-4 sm:mr-1 fill-current" xmlns="http://www.w3.org/2000/svg" <svg class="w-4 h-4 sm:mr-1 fill-current" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
@@ -94,12 +98,6 @@
<span class="ml-1 hidden sm:inline">GitHub</span> <span class="ml-1 hidden sm:inline">GitHub</span>
</a> </a>
</li> </li>
<li class="ml-1 hidden sm:block">
<a class="btn-sm text-zinc-100 bg-zinc-900 hover:bg-zinc-800 w-full shadow"
href="https://github.com/psviderski/uncloud/?tab=readme-ov-file#-quick-start">
Get Started
</a>
</li>
</ul> </ul>
</nav> </nav>
</div> </div>
@@ -596,33 +594,13 @@
</em> </em>
</h2> </h2>
<p class="text-lg text-zinc-500 mb-8"> <p class="text-lg text-zinc-500 mb-8">
Uncloud is an open source project I'm actively developing. I'd love to share this journey Subscribe to follow the progress and get early insights into new features.<br>
with you. Subscribe to follow the progress, get early insights into new features, and be the See <a href="https://psviderski.substack.com/" target="_blank" class="text-zinc-900 hover:underline">previous newsletters</a>.
first to know when it's ready for production use. No marketing, ads or spam. </p>
<p>
<!-- Subscribe form --> <!-- Subscribe form -->
<div class="flex flex-col sm:flex-row justify-center gap-4 items-stretch text-zinc-900"> <div class="flex justify-center">
<form action="https://app.kit.com/forms/7595321/subscriptions" <iframe src="https://psviderski.substack.com/embed" width="480" height="150" style="border:1px solid #EEE; background:white;" frameborder="0" scrolling="no"></iframe>
class="w-full max-w-sm mx-auto"
method="post" data-sv-form="7595321" data-uid="a6c3ce5004" data-format="inline"
data-version="5"
data-options="{&quot;settings&quot;:{&quot;after_subscribe&quot;:{&quot;action&quot;:&quot;message&quot;,&quot;success_message&quot;:&quot;Success! Please check your email to confirm your subscription.&quot;,&quot;redirect_url&quot;:&quot;&quot;},&quot;analytics&quot;:{&quot;google&quot;:null,&quot;fathom&quot;:null,&quot;facebook&quot;:null,&quot;segment&quot;:null,&quot;pinterest&quot;:null,&quot;sparkloop&quot;:null,&quot;googletagmanager&quot;:null},&quot;modal&quot;:{&quot;trigger&quot;:&quot;timer&quot;,&quot;scroll_percentage&quot;:null,&quot;timer&quot;:5,&quot;devices&quot;:&quot;all&quot;,&quot;show_once_every&quot;:15},&quot;powered_by&quot;:{&quot;show&quot;:true,&quot;url&quot;:&quot;https://kit.com/features/forms?utm_campaign=poweredby&amp;utm_content=form&amp;utm_medium=referral&amp;utm_source=dynamic&quot;},&quot;recaptcha&quot;:{&quot;enabled&quot;:false},&quot;return_visitor&quot;:{&quot;action&quot;:&quot;show&quot;,&quot;custom_content&quot;:&quot;&quot;},&quot;slide_in&quot;:{&quot;display_in&quot;:&quot;bottom_right&quot;,&quot;trigger&quot;:&quot;timer&quot;,&quot;scroll_percentage&quot;:null,&quot;timer&quot;:5,&quot;devices&quot;:&quot;all&quot;,&quot;show_once_every&quot;:15},&quot;sticky_bar&quot;:{&quot;display_in&quot;:&quot;top&quot;,&quot;trigger&quot;:&quot;timer&quot;,&quot;scroll_percentage&quot;:null,&quot;timer&quot;:5,&quot;devices&quot;:&quot;all&quot;,&quot;show_once_every&quot;:15}},&quot;version&quot;:&quot;5&quot;}"
>
<div data-element="fields" class="flex">
<input class="flex-1 min-w-0 form-input px-4 py-2 text-sm text-zinc-900 bg-white border border-zinc-200 rounded-l border-r-0 rounded-r-none shadow-sm focus:border-zinc-900"
name="email_address"
aria-label="Your email address"
placeholder="Your email address"
type="email"
required>
<button class="btn text-zinc-100 bg-zinc-900 hover:bg-zinc-800 rounded-l-none shadow"
data-element="submit">
Subscribe
</button>
</div>
<ul class="text-sm text-rose-700 mt-2" data-element="errors" data-group="alert"></ul>
</form>
</div> </div>
<div class="mt-6 text-sm text-zinc-500"> <div class="mt-6 text-sm text-zinc-500">
@@ -754,8 +732,6 @@
</div> </div>
<script src="https://f.convertkit.com/ckjs/ck.5.js"></script>
</body> </body>
</html> </html>
+8 -74
View File
@@ -1316,10 +1316,6 @@ input[type="search"]::-webkit-search-results-decoration {
width: 100%; width: 100%;
} }
.min-w-0{
min-width: 0px;
}
.max-w-3xl{ .max-w-3xl{
max-width: 48rem; max-width: 48rem;
} }
@@ -1336,10 +1332,6 @@ input[type="search"]::-webkit-search-results-decoration {
max-width: 28rem; max-width: 28rem;
} }
.max-w-sm{
max-width: 24rem;
}
.max-w-xl{ .max-w-xl{
max-width: 36rem; max-width: 36rem;
} }
@@ -1348,10 +1340,6 @@ input[type="search"]::-webkit-search-results-decoration {
max-width: 20rem; max-width: 20rem;
} }
.flex-1{
flex: 1 1 0%;
}
.shrink-0{ .shrink-0{
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1377,10 +1365,6 @@ input[type="search"]::-webkit-search-results-decoration {
flex-direction: column; flex-direction: column;
} }
.flex-wrap{
flex-wrap: wrap;
}
.items-start{ .items-start{
align-items: flex-start; align-items: flex-start;
} }
@@ -1393,10 +1377,6 @@ input[type="search"]::-webkit-search-results-decoration {
align-items: center; align-items: center;
} }
.items-stretch{
align-items: stretch;
}
.justify-end{ .justify-end{
justify-content: flex-end; justify-content: flex-end;
} }
@@ -1417,10 +1397,6 @@ input[type="search"]::-webkit-search-results-decoration {
gap: 0.75rem; gap: 0.75rem;
} }
.gap-4{
gap: 1rem;
}
.gap-6{ .gap-6{
gap: 1.5rem; gap: 1.5rem;
} }
@@ -1499,21 +1475,6 @@ input[type="search"]::-webkit-search-results-decoration {
border-radius: 0.75rem; border-radius: 0.75rem;
} }
.rounded-l{
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
.rounded-l-none{
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
.rounded-r-none{
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
.border{ .border{
border-width: 1px; border-width: 1px;
} }
@@ -1530,10 +1491,6 @@ input[type="search"]::-webkit-search-results-decoration {
border-bottom-width: 2px; border-bottom-width: 2px;
} }
.border-r-0{
border-right-width: 0px;
}
.border-t{ .border-t{
border-top-width: 1px; border-top-width: 1px;
} }
@@ -1664,16 +1621,16 @@ input[type="search"]::-webkit-search-results-decoration {
padding-right: 0.125rem; padding-right: 0.125rem;
} }
.px-1{
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.px-2{ .px-2{
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.px-3{
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.px-4{ .px-4{
padding-left: 1rem; padding-left: 1rem;
padding-right: 1rem; padding-right: 1rem;
@@ -1838,11 +1795,6 @@ input[type="search"]::-webkit-search-results-decoration {
color: rgb(79 70 229 / var(--tw-text-opacity)); color: rgb(79 70 229 / var(--tw-text-opacity));
} }
.text-rose-700{
--tw-text-opacity: 1;
color: rgb(190 18 60 / var(--tw-text-opacity));
}
.text-transparent{ .text-transparent{
color: transparent; color: transparent;
} }
@@ -2115,11 +2067,6 @@ input[type="search"]::-webkit-search-results-decoration {
border-color: transparent; border-color: transparent;
} }
.focus\:border-zinc-900:focus{
--tw-border-opacity: 1;
border-color: rgb(24 24 27 / var(--tw-border-opacity));
}
.focus\:bg-gray-200:focus{ .focus\:bg-gray-200:focus{
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity)); background-color: rgb(229 231 235 / var(--tw-bg-opacity));
@@ -2203,10 +2150,6 @@ input[type="search"]::-webkit-search-results-decoration {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
.sm\:block{
display: block;
}
.sm\:inline{ .sm\:inline{
display: inline; display: inline;
} }
@@ -2227,10 +2170,6 @@ input[type="search"]::-webkit-search-results-decoration {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.sm\:flex-row{
flex-direction: row;
}
.sm\:justify-center{ .sm\:justify-center{
justify-content: center; justify-content: center;
} }
@@ -2255,9 +2194,9 @@ input[type="search"]::-webkit-search-results-decoration {
padding: 1.5rem; padding: 1.5rem;
} }
.sm\:px-3{ .sm\:px-5{
padding-left: 0.75rem; padding-left: 1.25rem;
padding-right: 0.75rem; padding-right: 1.25rem;
} }
.sm\:px-6{ .sm\:px-6{
@@ -2390,11 +2329,6 @@ input[type="search"]::-webkit-search-results-decoration {
margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse))); margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0px * var(--tw-space-y-reverse)); margin-bottom: calc(0px * var(--tw-space-y-reverse));
} }
.lg\:px-5{
padding-left: 1.25rem;
padding-right: 1.25rem;
}
} }
@media (min-width: 1536px){ @media (min-width: 1536px){