mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef4b2213f | ||
|
|
44013da332 | ||
|
|
b48ebe2acc | ||
|
|
eaa4455320 | ||
|
|
ce3cb8a34f | ||
|
|
e6705599b3 | ||
|
|
7b88040c73 | ||
|
|
7a9eac593f | ||
|
|
17c1bb5c21 | ||
|
|
a5a9ef5444 | ||
|
|
2940965e31 | ||
|
|
185cf19a0c | ||
|
|
4443fa9e0e | ||
|
|
3056e2af11 | ||
|
|
7bfaf31138 | ||
|
|
f8d1945544 | ||
|
|
93d7f29d31 | ||
|
|
18ebd29032 | ||
|
|
fe56d6d708 | ||
|
|
14056cc584 | ||
|
|
d3ee7399eb | ||
|
|
5c54e9d059 | ||
|
|
ab6f856987 | ||
|
|
6e59657fe2 | ||
|
|
215f21439d | ||
|
|
53bf446502 | ||
|
|
2588279e52 | ||
|
|
f48dc250a9 | ||
|
|
15d9ceb4d1 | ||
|
|
a4a8e70f9c | ||
|
|
1d4dbd96ce | ||
|
|
476a2d57ca | ||
|
|
ce46a67d6d | ||
|
|
87e49dfb62 | ||
|
|
808bda67d4 | ||
|
|
c91a964513 | ||
|
|
e0a63a3f49 | ||
|
|
0b2e1bb7a2 | ||
|
|
7c4f739d07 | ||
|
|
3bd9400051 |
@@ -39,3 +39,4 @@ node_modules/
|
|||||||
|
|
||||||
# VS Code
|
# VS Code
|
||||||
.vscode
|
.vscode
|
||||||
|
.devcontainer/
|
||||||
|
|||||||
+38
-31
@@ -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."
|
||||||
+16
-10
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:"-"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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, ", "))
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, ", "))
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 ""
|
||||||
|
|||||||
@@ -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+"-"))
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
@@ -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{
|
||||||
|
|||||||
@@ -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
@@ -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="{"settings":{"after_subscribe":{"action":"message","success_message":"Success! Please check your email to confirm your subscription.","redirect_url":""},"analytics":{"google":null,"fathom":null,"facebook":null,"segment":null,"pinterest":null,"sparkloop":null,"googletagmanager":null},"modal":{"trigger":"timer","scroll_percentage":null,"timer":5,"devices":"all","show_once_every":15},"powered_by":{"show":true,"url":"https://kit.com/features/forms?utm_campaign=poweredby&utm_content=form&utm_medium=referral&utm_source=dynamic"},"recaptcha":{"enabled":false},"return_visitor":{"action":"show","custom_content":""},"slide_in":{"display_in":"bottom_right","trigger":"timer","scroll_percentage":null,"timer":5,"devices":"all","show_once_every":15},"sticky_bar":{"display_in":"top","trigger":"timer","scroll_percentage":null,"timer":5,"devices":"all","show_once_every":15}},"version":"5"}"
|
|
||||||
>
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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){
|
||||||
|
|||||||
Reference in New Issue
Block a user