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
|
||||
.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
|
||||
|
||||
**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
|
||||
|
||||
@@ -68,29 +72,29 @@ This document provides comprehensive information about the Uncloud project for A
|
||||
|
||||
- **`cmd/`**: Contains main applications
|
||||
|
||||
- `uncloud/`: CLI tool with subcommands for machine, service, volume management
|
||||
- `uncloudd/`: Daemon that runs on each machine
|
||||
- `ucind/`: Development cluster management for testing
|
||||
- `uncloud/`: CLI tool with subcommands for machine, service, volume management
|
||||
- `uncloudd/`: Daemon that runs on each machine
|
||||
- `ucind/`: Development cluster management for testing
|
||||
|
||||
- **`internal/`**: Internal implementation packages
|
||||
|
||||
- `cli/`: Command-line interface logic
|
||||
- `machine/`: Machine lifecycle and state management
|
||||
- `daemon/`: Daemon implementation and gRPC services
|
||||
- `dns/`: Internal DNS server for service discovery
|
||||
- `cli/`: Command-line interface logic
|
||||
- `machine/`: Machine lifecycle and state management
|
||||
- `daemon/`: Daemon implementation and gRPC services
|
||||
- `dns/`: Internal DNS server for service discovery
|
||||
|
||||
- **`pkg/`**: Public API packages for external use
|
||||
|
||||
- `api/`: Core API types and definitions
|
||||
- `client/`: Client libraries for interacting with Uncloud
|
||||
- `api/`: Core API types and definitions
|
||||
- `client/`: Client libraries for interacting with Uncloud
|
||||
|
||||
- **`experiment/`**: Experimental features and prototypes
|
||||
- **`scripts/`**: Installation and utility scripts
|
||||
- **`test/`**: Test suites and test infrastructure
|
||||
- **`website/`**: Documentation website (Docusaurus)
|
||||
|
||||
- `landing/`: Landing page
|
||||
- `docs/`: User documentation
|
||||
- `landing/`: Landing page
|
||||
- `docs/`: User documentation
|
||||
|
||||
- **`misc/`**: Design documents and guides
|
||||
|
||||
@@ -100,25 +104,25 @@ This document provides comprehensive information about the Uncloud project for A
|
||||
|
||||
```go
|
||||
// Networking and orchestration
|
||||
github.com/docker/docker // Docker API client
|
||||
github.com/docker/compose/v2 // Docker Compose integration
|
||||
golang.zx2c4.com/wireguard // WireGuard implementation
|
||||
github.com/hashicorp/serf // Gossip protocol
|
||||
github.com/docker/docker // Docker API client
|
||||
github.com/docker/compose/v2 // Docker Compose integration
|
||||
golang.zx2c4.com/wireguard // WireGuard implementation
|
||||
github.com/hashicorp/serf // Gossip protocol
|
||||
|
||||
// State management
|
||||
github.com/ipfs/go-ds-crdt // CRDT distributed storage
|
||||
github.com/dgraph-io/badger/v3 // Embedded database
|
||||
github.com/ipfs/go-ds-crdt // CRDT distributed storage
|
||||
github.com/dgraph-io/badger/v3 // Embedded database
|
||||
|
||||
// Web proxy
|
||||
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
|
||||
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
|
||||
|
||||
// CLI and UX
|
||||
github.com/spf13/cobra // CLI framework
|
||||
github.com/charmbracelet/huh // Interactive forms
|
||||
github.com/spf13/cobra // CLI framework
|
||||
github.com/charmbracelet/huh // Interactive forms
|
||||
|
||||
// gRPC and networking
|
||||
google.golang.org/grpc // gRPC framework
|
||||
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
|
||||
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
@@ -189,9 +193,9 @@ uc context use <name> # Switch context
|
||||
### Testing
|
||||
|
||||
- Test files and locations
|
||||
- Unit tests alongside source files (`*_test.go`)
|
||||
- Integration tests in `test/e2e/`
|
||||
- Test fixtures in `test/fixtures/`
|
||||
- Unit tests alongside source files (`*_test.go`)
|
||||
- Integration tests in `test/e2e/`
|
||||
- Test fixtures in `test/fixtures/`
|
||||
- Use table driven tests whenever possible
|
||||
|
||||
### Dependencies
|
||||
@@ -225,8 +229,8 @@ uc context use <name> # Switch context
|
||||
### Logs and Monitoring
|
||||
|
||||
- Systemd services (getting logs via `journalctl -u SERVICE_NAME`)
|
||||
- `uncloud` -- Uncloud daemon
|
||||
- `uncloud-corrosion` -- Corrosion process
|
||||
- `uncloud` -- Uncloud daemon
|
||||
- `uncloud-corrosion` -- Corrosion process
|
||||
- Machine daemon logs
|
||||
- Container logs via Docker
|
||||
|
||||
@@ -247,7 +251,8 @@ uc context use <name> # Switch context
|
||||
- `Makefile`: Build and development tasks
|
||||
- `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing)
|
||||
|
||||
This document should help AI assistants understand the project structure, make informed suggestions, and contribute effectively to the Uncloud codebase.
|
||||
This document should help AI assistants understand the project structure, make informed suggestions, and contribute
|
||||
effectively to the Uncloud codebase.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -255,4 +260,6 @@ Instructions when generating documentation:
|
||||
|
||||
- Use conversational language – write as if you were speaking to a friend.
|
||||
- Keep sentences simple, optimize for clarity and understanding.
|
||||
- Place the subject before the action whenever possible. Example: prefer “The function loads data” over “Data is loaded by the function.”
|
||||
- Do not use em dashes (—) or semicolons (;) in sentences. Instead, break complex sentences into simpler ones.
|
||||
- Place the subject before the action whenever possible. Example: prefer "The function loads data" over "Data is loaded
|
||||
by the function."
|
||||
+16
-10
@@ -14,18 +14,20 @@ import (
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type deployOptions struct {
|
||||
cli.BuildServicesOptions
|
||||
|
||||
files []string
|
||||
profiles []string
|
||||
services []string
|
||||
noBuild bool
|
||||
recreate bool
|
||||
yes bool
|
||||
files []string
|
||||
profiles []string
|
||||
services []string
|
||||
noBuild bool
|
||||
recreate bool
|
||||
skipHealth bool
|
||||
yes bool
|
||||
}
|
||||
|
||||
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
||||
@@ -60,6 +62,10 @@ func NewDeployCommand() *cobra.Command {
|
||||
"One or more Compose profiles to enable.")
|
||||
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
||||
"Recreate containers even if their configuration and image haven't changed.")
|
||||
cmd.Flags().BoolVar(&opts.skipHealth, "skip-health", false,
|
||||
"Skip the monitoring period and health checks after starting new containers. Useful for faster emergency "+
|
||||
"deployments.\n"+
|
||||
"Warning: This may cause downtime if new containers fail to start properly.")
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+
|
||||
"e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
@@ -148,9 +154,9 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
var strategy deploy.Strategy
|
||||
if opts.recreate {
|
||||
strategy = &deploy.RollingStrategy{ForceRecreate: true}
|
||||
strategy := &deploy.RollingStrategy{
|
||||
ForceRecreate: opts.recreate,
|
||||
SkipHealthMonitor: opts.skipHealth,
|
||||
}
|
||||
composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy)
|
||||
if err != nil {
|
||||
@@ -198,7 +204,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
}, uncli.ProgressOut(), "Deploying services")
|
||||
}
|
||||
|
||||
func printPlan(ctx context.Context, cli *client.Client, plan deploy.SequenceOperation) error {
|
||||
func printPlan(ctx context.Context, cli *client.Client, plan operation.SequenceOperation) error {
|
||||
for _, op := range plan.Operations {
|
||||
svcPlan, ok := op.(*deploy.Plan)
|
||||
if !ok {
|
||||
|
||||
@@ -66,6 +66,7 @@ require (
|
||||
google.golang.org/grpc v1.74.2
|
||||
google.golang.org/protobuf v1.36.9
|
||||
modernc.org/sqlite v1.36.3
|
||||
tags.cncf.io/container-device-interface v1.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -415,5 +416,4 @@ require (
|
||||
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
tags.cncf.io/container-device-interface v1.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -11,11 +11,6 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultSSHUser = "root"
|
||||
DefaultSSHPort = 22
|
||||
)
|
||||
|
||||
type MachineConnection struct {
|
||||
SSH SSHDestination `yaml:"ssh,omitempty"`
|
||||
SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"`
|
||||
@@ -30,7 +25,7 @@ type MachineConnection struct {
|
||||
MachineID string `yaml:"machine_id,omitempty"`
|
||||
}
|
||||
|
||||
func (c MachineConnection) String() string {
|
||||
func (c *MachineConnection) String() string {
|
||||
if c.SSH != "" {
|
||||
return "ssh://" + string(c.SSH)
|
||||
} else if c.SSHCLI != "" {
|
||||
@@ -69,36 +64,37 @@ func (c *MachineConnection) Validate() error {
|
||||
}
|
||||
|
||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||
// The default user "root" and port 22 can be omitted.
|
||||
// Empty user or port components are omitted.
|
||||
type SSHDestination string
|
||||
|
||||
// NewSSHDestination constructs an SSHDestination from user, host, and port components.
|
||||
// If user is empty, it is omitted.
|
||||
// If port is 0, it is omitted.
|
||||
func NewSSHDestination(user, host string, port int) SSHDestination {
|
||||
dst := host
|
||||
if port != 0 && port != DefaultSSHPort {
|
||||
if port != 0 {
|
||||
dst = net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
if user == "" {
|
||||
user = DefaultSSHUser
|
||||
if user != "" {
|
||||
dst = fmt.Sprintf("%s@%s", user, dst)
|
||||
}
|
||||
dst = user + "@" + dst
|
||||
|
||||
return SSHDestination(dst)
|
||||
}
|
||||
|
||||
// Parse parses the SSH destination string into user, host, and port components.
|
||||
// If user is not specified, it returns an empty string.
|
||||
// If port is not specified, it returns 0.
|
||||
func (d SSHDestination) Parse() (user string, host string, port int, err error) {
|
||||
host = string(d)
|
||||
if strings.Contains(host, "@") {
|
||||
user, host, _ = strings.Cut(host, "@")
|
||||
}
|
||||
if user == "" {
|
||||
user = DefaultSSHUser
|
||||
}
|
||||
h, p, sErr := net.SplitHostPort(host)
|
||||
if sErr == nil {
|
||||
host = h
|
||||
port, err = strconv.Atoi(p)
|
||||
}
|
||||
if port == 0 {
|
||||
port = DefaultSSHPort
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
@@ -566,6 +567,22 @@ func (s *Server) CreateServiceContainer(
|
||||
if spec.Mode == "" {
|
||||
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
|
||||
}
|
||||
if hc := spec.Container.Healthcheck; hc != nil {
|
||||
if hc.Disable {
|
||||
config.Healthcheck = &container.HealthConfig{
|
||||
Test: []string{"NONE"},
|
||||
}
|
||||
} else {
|
||||
config.Healthcheck = &container.HealthConfig{
|
||||
Test: hc.Test,
|
||||
Interval: hc.Interval,
|
||||
Timeout: hc.Timeout,
|
||||
StartPeriod: hc.StartPeriod,
|
||||
StartInterval: hc.StartInterval,
|
||||
Retries: int(hc.Retries),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB.
|
||||
var err error
|
||||
@@ -616,7 +633,9 @@ func (s *Server) CreateServiceContainer(
|
||||
NanoCPUs: spec.Container.Resources.CPU,
|
||||
Memory: spec.Container.Resources.Memory,
|
||||
MemoryReservation: spec.Container.Resources.MemoryReservation,
|
||||
Devices: toDockerDevices(spec.Container.Resources.Devices),
|
||||
DeviceRequests: spec.Container.Resources.DeviceReservations,
|
||||
Ulimits: toDockerUlimits(spec.Container.Resources.Ulimits),
|
||||
},
|
||||
// Restart service containers if they exit or a machine restarts unless they are explicitly stopped.
|
||||
// For one-off containers and batch jobs we plan to use a different service type/mode.
|
||||
@@ -877,6 +896,40 @@ func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
||||
return dockerOpts
|
||||
}
|
||||
|
||||
func toDockerUlimits(ulimits map[string]api.Ulimit) []*units.Ulimit {
|
||||
if len(ulimits) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dockerUlimits := make([]*units.Ulimit, 0, len(ulimits))
|
||||
for name, u := range ulimits {
|
||||
dockerUlimits = append(dockerUlimits, &units.Ulimit{
|
||||
Name: name,
|
||||
Soft: u.Soft,
|
||||
Hard: u.Hard,
|
||||
})
|
||||
}
|
||||
|
||||
return dockerUlimits
|
||||
}
|
||||
|
||||
func toDockerDevices(devices []api.DeviceMapping) []container.DeviceMapping {
|
||||
if len(devices) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dockerDevices := make([]container.DeviceMapping, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
dockerDevices = append(dockerDevices, container.DeviceMapping{
|
||||
PathOnHost: d.HostPath,
|
||||
PathInContainer: d.ContainerPath,
|
||||
CgroupPermissions: d.CgroupPermissions,
|
||||
})
|
||||
}
|
||||
|
||||
return dockerDevices
|
||||
}
|
||||
|
||||
// verifyDockerVolumesExist checks if the Docker named volumes referenced in the mounts exist on the machine.
|
||||
func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mount) error {
|
||||
for _, m := range mounts {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
osuser "os/user"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -13,7 +14,17 @@ import (
|
||||
)
|
||||
|
||||
func Connect(user, host string, port int, sshKeyPath string) (*ssh.Client, error) {
|
||||
// Use the current OS user if no user is specified to be make it consistent with ssh CLI behavior.
|
||||
if user == "" {
|
||||
if u, err := osuser.Current(); err == nil {
|
||||
user = u.Username
|
||||
}
|
||||
}
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
// Try to connect using SSH agent only.
|
||||
agentAuth, agentClose, agentErr := sshAgentAuth()
|
||||
if agentErr == nil {
|
||||
|
||||
@@ -30,7 +30,7 @@ func NewSSHCLIRemote(user, host string, port int, keyPath string) *SSHCLIRemote
|
||||
func (r *SSHCLIRemote) buildSSHArgs() []string {
|
||||
args := []string{"-o", "ConnectTimeout=5"}
|
||||
|
||||
if r.port != 0 && r.port != 22 {
|
||||
if r.port != 0 {
|
||||
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, r.user+"@"+r.host)
|
||||
dst := r.host
|
||||
if r.user != "" {
|
||||
dst = fmt.Sprintf("%s@%s", r.user, dst)
|
||||
}
|
||||
args = append(args, dst)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
|
||||
@@ -18,29 +18,49 @@ func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
|
||||
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",
|
||||
host: "example.com",
|
||||
port: 22,
|
||||
keyPath: "",
|
||||
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",
|
||||
host: "192.168.1.10",
|
||||
port: 2222,
|
||||
keyPath: "",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "ubuntu@192.168.1.10"},
|
||||
},
|
||||
{
|
||||
name: "with key path",
|
||||
name: "user and key",
|
||||
user: "root",
|
||||
host: "example.com",
|
||||
port: 22,
|
||||
keyPath: "/path/to/key",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com"},
|
||||
},
|
||||
{
|
||||
name: "port and key",
|
||||
host: "example.com",
|
||||
port: 22,
|
||||
keyPath: "~/.ssh/id_rsa",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-p", "22", "-i", "~/.ssh/id_rsa", "example.com"},
|
||||
},
|
||||
{
|
||||
name: "all options",
|
||||
user: "admin",
|
||||
@@ -49,14 +69,6 @@ func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
|
||||
keyPath: "~/.ssh/id_rsa",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "~/.ssh/id_rsa", "admin@server.local"},
|
||||
},
|
||||
{
|
||||
name: "port zero (default)",
|
||||
user: "root",
|
||||
host: "example.com",
|
||||
port: 0,
|
||||
keyPath: "",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+10
-2
@@ -21,11 +21,14 @@ type ContainerClient interface {
|
||||
CreateContainer(
|
||||
ctx context.Context, serviceID string, spec ServiceSpec, machineID string,
|
||||
) (container.CreateResponse, error)
|
||||
ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error)
|
||||
InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error)
|
||||
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
|
||||
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
|
||||
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
|
||||
ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error)
|
||||
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
|
||||
WaitContainerHealthy(
|
||||
ctx context.Context, serviceNameOrID, containerNameOrID string, opts WaitContainerHealthyOptions,
|
||||
) error
|
||||
}
|
||||
|
||||
type DNSClient interface {
|
||||
@@ -57,3 +60,8 @@ type VolumeClient interface {
|
||||
ListVolumes(ctx context.Context, filter *VolumeFilter) ([]MachineVolume, 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -41,6 +43,19 @@ func (c *Container) CreatedTime() time.Time {
|
||||
return c.created
|
||||
}
|
||||
|
||||
// HasHealthcheck returns true if the container has a health check configured.
|
||||
func (c *Container) HasHealthcheck() bool {
|
||||
hc := c.Config.Healthcheck
|
||||
if hc == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(hc.Test) > 0 && hc.Test[0] == "NONE" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Healthy determines if the container is running and healthy.
|
||||
// A running container with no health check configured is considered healthy.
|
||||
func (c *Container) Healthy() bool {
|
||||
@@ -244,3 +259,61 @@ func (c *ServiceContainer) UnmarshalJSON(data []byte) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultHealthMonitorPeriod is the default duration (5 seconds) to wait before checking that the container is still
|
||||
// running and not restarting. Can be overridden with the UNCLOUD_HEALTH_MONITOR_PERIOD environment variable
|
||||
// (e.g. "10s" or "0").
|
||||
var DefaultHealthMonitorPeriod = defaultHealthMonitorPeriod()
|
||||
|
||||
func defaultHealthMonitorPeriod() time.Duration {
|
||||
if v, ok := os.LookupEnv("UNCLOUD_HEALTH_MONITOR_PERIOD"); ok {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
return 5 * time.Second
|
||||
}
|
||||
|
||||
// WaitContainerHealthyOptions configures the behaviour of WaitContainerHealthy.
|
||||
type WaitContainerHealthyOptions struct {
|
||||
// MonitorPeriod is how long to wait before checking that the container is still running and not restarting.
|
||||
// Containers with a health check that become healthy before the period ends succeed early.
|
||||
// nil means use the default DefaultHealthMonitorPeriod.
|
||||
// Zero skips the monitoring and checks the container's health immediately after starting.
|
||||
MonitorPeriod *time.Duration
|
||||
}
|
||||
|
||||
// ExecOptions contains configuration for executing a command in a container.
|
||||
type ExecOptions struct {
|
||||
// Command is the command to run in the container.
|
||||
Command []string
|
||||
// AttachStdin attaches the stdin stream to the exec session.
|
||||
AttachStdin bool
|
||||
// AttachStdout attaches the stdout stream to the exec session.
|
||||
AttachStdout bool
|
||||
// AttachStderr attaches the stderr stream to the exec session.
|
||||
AttachStderr bool
|
||||
// Tty allocates a pseudo-TTY for the exec session.
|
||||
Tty bool
|
||||
// Detach runs the command in the background without attaching to streams.
|
||||
Detach bool
|
||||
|
||||
//// Not yet implemented fields
|
||||
// User specifies the user to run the command as.
|
||||
User string
|
||||
// Privileged runs the command in privileged mode.
|
||||
Privileged bool
|
||||
// WorkingDir sets the working directory for the command.
|
||||
WorkingDir string
|
||||
// Env sets environment variables for the command.
|
||||
Env []string
|
||||
|
||||
// Client-side only fields (not serialized, not sent to server)
|
||||
// Stdin is the input stream. Defaults to os.Stdin if nil.
|
||||
Stdin io.Reader `json:"-"`
|
||||
// Stdout is the output stream. Defaults to os.Stdout if nil.
|
||||
Stdout io.Writer `json:"-"`
|
||||
// Stderr is the error stream. Defaults to os.Stderr if nil.
|
||||
Stderr io.Writer `json:"-"`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// TODO: implement a placement constraint that checks available memory on machines.
|
||||
MemoryReservation int64
|
||||
// Device reservations/requests for access to things like GPUs
|
||||
// Devices provides direct access to host devices.
|
||||
Devices []DeviceMapping
|
||||
// DeviceReservations requests for access to things like GPUs.
|
||||
DeviceReservations []container.DeviceRequest
|
||||
// Ulimits defines the resource limits for the container.
|
||||
Ulimits map[string]Ulimit
|
||||
}
|
||||
|
||||
// DeviceMapping represents a device mapping between host and container.
|
||||
type DeviceMapping struct {
|
||||
// HostPath is the path to the device on the host.
|
||||
HostPath string
|
||||
// ContainerPath is the path to the device in the container.
|
||||
ContainerPath string
|
||||
// CgroupPermissions is the cgroup permissions for the device (e.g., "rwm").
|
||||
CgroupPermissions string
|
||||
}
|
||||
|
||||
type Ulimit struct {
|
||||
Soft int64
|
||||
Hard int64
|
||||
}
|
||||
|
||||
+69
-8
@@ -7,6 +7,7 @@ import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
"github.com/distribution/reference"
|
||||
@@ -19,6 +20,13 @@ const (
|
||||
ServiceModeReplicated = "replicated"
|
||||
ServiceModeGlobal = "global"
|
||||
|
||||
// UpdateOrderStartFirst starts the new container before stopping the old one.
|
||||
// This minimizes downtime but briefly runs both containers.
|
||||
UpdateOrderStartFirst = "start-first"
|
||||
// UpdateOrderStopFirst stops the old container before starting the new one.
|
||||
// This prevents data corruption for stateful services but causes brief downtime.
|
||||
UpdateOrderStopFirst = "stop-first"
|
||||
|
||||
// PullPolicyAlways means the image is always pulled from the registry.
|
||||
PullPolicyAlways = "always"
|
||||
// PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where
|
||||
@@ -59,6 +67,8 @@ type ServiceSpec struct {
|
||||
Ports []PortSpec
|
||||
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
||||
Replicas uint `json:",omitempty"`
|
||||
// UpdateConfig configures how the service is updated during a deployment.
|
||||
UpdateConfig UpdateConfig `json:",omitempty"`
|
||||
// Volumes is list of data volumes that can be mounted into the container.
|
||||
Volumes []VolumeSpec
|
||||
// Configs is list of configuration objects that can be mounted into the container.
|
||||
@@ -234,8 +244,11 @@ type ContainerSpec struct {
|
||||
// Entrypoint overrides the default ENTRYPOINT of the image.
|
||||
Entrypoint []string
|
||||
// Env defines the environment variables to set inside the container.
|
||||
Env EnvVars
|
||||
Image string
|
||||
Env EnvVars
|
||||
// Healthcheck defines the health check configuration for the container or overrides the health check options
|
||||
// defined in the image. If nil, the image's default health check is used.
|
||||
Healthcheck *HealthcheckSpec `json:",omitempty"`
|
||||
Image string
|
||||
// Run a custom init inside the container. If nil, use the daemon's configured settings.
|
||||
Init *bool
|
||||
// LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default.
|
||||
@@ -330,6 +343,17 @@ func (s *ContainerSpec) Clone() ContainerSpec {
|
||||
spec.Entrypoint = make([]string, len(s.Entrypoint))
|
||||
copy(spec.Entrypoint, s.Entrypoint)
|
||||
}
|
||||
if s.Env != nil {
|
||||
spec.Env = make(EnvVars, len(s.Env))
|
||||
for k, v := range s.Env {
|
||||
spec.Env[k] = v
|
||||
}
|
||||
}
|
||||
if s.Healthcheck != nil {
|
||||
hc := *s.Healthcheck
|
||||
hc.Test = slices.Clone(s.Healthcheck.Test)
|
||||
spec.Healthcheck = &hc
|
||||
}
|
||||
if s.LogDriver != nil {
|
||||
logDriver := *s.LogDriver
|
||||
if s.LogDriver.Options != nil {
|
||||
@@ -337,12 +361,6 @@ func (s *ContainerSpec) Clone() ContainerSpec {
|
||||
}
|
||||
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 {
|
||||
spec.Volumes = make([]string, len(s.Volumes))
|
||||
copy(spec.Volumes, s.Volumes)
|
||||
@@ -363,6 +381,16 @@ func (s *ContainerSpec) Clone() ContainerSpec {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -380,6 +408,26 @@ func (e EnvVars) ToSlice() []string {
|
||||
return env
|
||||
}
|
||||
|
||||
// HealthcheckSpec defines the health check configuration for a container.
|
||||
type HealthcheckSpec struct {
|
||||
// Test is the command used to check health.
|
||||
// Formats: ["CMD", args...], ["CMD-SHELL", "command"], or ["NONE"] to disable.
|
||||
Test []string `json:",omitempty"`
|
||||
// Interval is the time between health checks.
|
||||
// Zero means to inherit the value from the image or use the Docker default (30s) if not defined in the image.
|
||||
Interval time.Duration `json:",omitempty"`
|
||||
// Timeout is how long to wait before considering the checck to have hung.
|
||||
Timeout time.Duration `json:",omitempty"`
|
||||
// StartPeriod is the initialisation time for a container before the retries start to count down.
|
||||
StartPeriod time.Duration `json:",omitempty"`
|
||||
// StartInterval is the time between health checks during the start period.
|
||||
StartInterval time.Duration `json:",omitempty"`
|
||||
// Retries is the number of consecutive failures needed to consider a container unhealthy.
|
||||
Retries uint `json:",omitempty"`
|
||||
// Disable disables the health check defined in the image. true is equivalent to setting Test to ["NONE"].
|
||||
Disable bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
type LogDriver struct {
|
||||
// Name of the logging driver to use.
|
||||
Name string
|
||||
@@ -387,6 +435,19 @@ type LogDriver struct {
|
||||
Options map[string]string
|
||||
}
|
||||
|
||||
// UpdateConfig configures how a service is updated during a deployment.
|
||||
type UpdateConfig struct {
|
||||
// Order specifies the order of operations during an update.
|
||||
// Valid values are "start-first" (default for stateless services) and "stop-first" (default for services with
|
||||
// volumes). Empty value means the strategy will determine the order based on service characteristics.
|
||||
Order string `json:",omitempty"`
|
||||
// MonitorPeriod is how long to wait after starting a container before checking that it's still running
|
||||
// and not restarting. Containers with a health check that become healthy before the period ends succeed early.
|
||||
// nil means use the default api.DefaultHealthMonitorPeriod.
|
||||
// Zero skips the monitoring and checks the container's health immediately after starting.
|
||||
MonitorPeriod *time.Duration `json:",omitempty"`
|
||||
}
|
||||
|
||||
type RunServiceResponse struct {
|
||||
ID string
|
||||
Name string
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -231,6 +232,12 @@ func TestContainerSpec_Clone(t *testing.T) {
|
||||
CPU: 1234,
|
||||
Memory: 2345,
|
||||
MemoryReservation: 3456,
|
||||
Devices: []DeviceMapping{
|
||||
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
|
||||
},
|
||||
DeviceReservations: []container.DeviceRequest{
|
||||
{Count: 1, Capabilities: [][]string{{"gpu"}}, Driver: "nvidia"},
|
||||
},
|
||||
},
|
||||
Sysctls: map[string]string{
|
||||
"net.ipv4.ip_forward": "1",
|
||||
@@ -263,6 +270,9 @@ func TestContainerSpec_Clone(t *testing.T) {
|
||||
original.ConfigMounts[0].ContainerPath = stringModified
|
||||
*original.ConfigMounts[0].Mode = 0o755 // Modify the Mode pointer value
|
||||
original.Sysctls["net.ipv4.ip_forward"] = stringModified
|
||||
original.Resources.Devices[0].HostPath = stringModified
|
||||
original.Resources.DeviceReservations[0].Count = 2
|
||||
original.Resources.DeviceReservations[0].Driver = stringModified
|
||||
|
||||
assert.False(t, original.Equals(cloned))
|
||||
// Assert cloned values are unchanged
|
||||
@@ -283,6 +293,9 @@ func TestContainerSpec_Clone(t *testing.T) {
|
||||
assert.Equal(t, int64(1234), cloned.Resources.CPU)
|
||||
assert.Equal(t, int64(2345), cloned.Resources.Memory)
|
||||
assert.Equal(t, int64(3456), cloned.Resources.MemoryReservation)
|
||||
assert.Equal(t, "/dev/sda", cloned.Resources.Devices[0].HostPath)
|
||||
assert.Equal(t, 1, cloned.Resources.DeviceReservations[0].Count)
|
||||
assert.Equal(t, "nvidia", cloned.Resources.DeviceReservations[0].Driver)
|
||||
assert.Equal(t, "1000:1000", cloned.User)
|
||||
assert.Equal(t, "/data", cloned.Volumes[0])
|
||||
assert.Equal(t, "/data", cloned.VolumeMounts[0].ContainerPath)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||
)
|
||||
|
||||
@@ -27,7 +28,7 @@ type Deployment struct {
|
||||
SpecResolver *deploy.ServiceSpecResolver
|
||||
Strategy deploy.Strategy
|
||||
state *scheduler.ClusterState
|
||||
plan *deploy.SequenceOperation
|
||||
plan *operation.SequenceOperation
|
||||
}
|
||||
|
||||
func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) {
|
||||
@@ -58,11 +59,11 @@ func NewDeploymentWithStrategy(ctx context.Context, cli Client, project *types.P
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error) {
|
||||
func (d *Deployment) Plan(ctx context.Context) (operation.SequenceOperation, error) {
|
||||
if d.plan != nil {
|
||||
return *d.plan, nil
|
||||
}
|
||||
plan := deploy.SequenceOperation{}
|
||||
plan := operation.SequenceOperation{}
|
||||
|
||||
// Generate service specs for all services in the project.
|
||||
var serviceSpecs []api.ServiceSpec
|
||||
@@ -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.
|
||||
func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.CreateVolumeOperation, error) {
|
||||
func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*operation.CreateVolumeOperation, error) {
|
||||
if len(d.Project.Volumes) == 0 {
|
||||
// No volumes to check or create.
|
||||
return nil, nil
|
||||
@@ -145,7 +146,7 @@ func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.Crea
|
||||
}
|
||||
|
||||
// Generate operations to create scheduled missing volumes.
|
||||
var ops []*deploy.CreateVolumeOperation
|
||||
var ops []*operation.CreateVolumeOperation
|
||||
for machineID, volumes := range scheduledVolumes {
|
||||
for _, v := range volumes {
|
||||
machineName := machineID
|
||||
@@ -153,7 +154,7 @@ func (d *Deployment) planVolumes(serviceSpecs []api.ServiceSpec) ([]*deploy.Crea
|
||||
machineName = m.Info.Name
|
||||
}
|
||||
|
||||
ops = append(ops, &deploy.CreateVolumeOperation{
|
||||
ops = append(ops, &operation.CreateVolumeOperation{
|
||||
MachineID: machineID,
|
||||
MachineName: machineName,
|
||||
VolumeSpec: v,
|
||||
|
||||
@@ -6,13 +6,23 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/compose-spec/compose-go/v2/transform"
|
||||
"github.com/compose-spec/compose-go/v2/tree"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
var registerComposeOverrides sync.Once
|
||||
|
||||
// LoadProject loads a Compose project from the default locations or the given paths.
|
||||
func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) {
|
||||
registerComposeOverrides.Do(func() {
|
||||
transform.RegisterDefaultValue("services.*.deploy.update_config", setUpdateConfigDefaults)
|
||||
})
|
||||
|
||||
defaultOpts := []composecli.ProjectOptionsFn{
|
||||
// First apply os.Environment, always wins.
|
||||
composecli.WithOsEnv,
|
||||
@@ -92,3 +102,14 @@ func removeProjectPrefixFromNames(project *types.Project) {
|
||||
project.Volumes[name] = vol
|
||||
}
|
||||
}
|
||||
|
||||
// setUpdateConfigDefaults sets default values for deploy.update_config attributes when not specified in the compose file.
|
||||
func setUpdateConfigDefaults(data any, _ tree.Path, _ bool) (any, error) {
|
||||
switch v := data.(type) {
|
||||
case map[string]any:
|
||||
if _, ok := v["monitor"]; !ok {
|
||||
v["monitor"] = api.DefaultHealthMonitorPeriod.String()
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
+108
-12
@@ -5,12 +5,14 @@ import (
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
cdi "tags.cncf.io/container-device-interface/pkg/parser"
|
||||
)
|
||||
|
||||
func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.ServiceSpec, error) {
|
||||
@@ -43,18 +45,19 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
||||
|
||||
spec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
CapAdd: service.CapAdd,
|
||||
CapDrop: service.CapDrop,
|
||||
Command: service.Command,
|
||||
Entrypoint: service.Entrypoint,
|
||||
Env: env,
|
||||
Image: service.Image,
|
||||
Init: service.Init,
|
||||
Privileged: service.Privileged,
|
||||
PullPolicy: pullPolicy,
|
||||
Resources: resourcesFromCompose(service),
|
||||
Sysctls: service.Sysctls,
|
||||
User: service.User,
|
||||
CapAdd: service.CapAdd,
|
||||
CapDrop: service.CapDrop,
|
||||
Command: service.Command,
|
||||
Entrypoint: service.Entrypoint,
|
||||
Env: env,
|
||||
Healthcheck: healthcheckFromCompose(service.HealthCheck),
|
||||
Image: service.Image,
|
||||
Init: service.Init,
|
||||
Privileged: service.Privileged,
|
||||
PullPolicy: pullPolicy,
|
||||
Resources: resourcesFromCompose(service),
|
||||
Sysctls: service.Sysctls,
|
||||
User: service.User,
|
||||
},
|
||||
Name: serviceName,
|
||||
Mode: api.ServiceModeReplicated,
|
||||
@@ -97,6 +100,22 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
||||
default:
|
||||
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
|
||||
}
|
||||
|
||||
if cfg := service.Deploy.UpdateConfig; cfg != nil {
|
||||
switch cfg.Order {
|
||||
case "":
|
||||
// No order specified, use default behavior.
|
||||
case "start-first":
|
||||
spec.UpdateConfig.Order = api.UpdateOrderStartFirst
|
||||
case "stop-first":
|
||||
spec.UpdateConfig.Order = api.UpdateOrderStopFirst
|
||||
default:
|
||||
return spec, fmt.Errorf("unsupported deploy.update_config.order: '%s'", cfg.Order)
|
||||
}
|
||||
|
||||
d := time.Duration(cfg.Monitor)
|
||||
spec.UpdateConfig.MonitorPeriod = &d
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
|
||||
@@ -120,11 +139,61 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func healthcheckFromCompose(hc *types.HealthCheckConfig) *api.HealthcheckSpec {
|
||||
if hc == nil {
|
||||
return nil
|
||||
}
|
||||
if hc.Disable {
|
||||
return &api.HealthcheckSpec{Disable: true}
|
||||
}
|
||||
|
||||
spec := &api.HealthcheckSpec{Test: hc.Test}
|
||||
if hc.Interval != nil {
|
||||
spec.Interval = time.Duration(*hc.Interval)
|
||||
}
|
||||
if hc.Timeout != nil {
|
||||
spec.Timeout = time.Duration(*hc.Timeout)
|
||||
}
|
||||
if hc.StartPeriod != nil {
|
||||
spec.StartPeriod = time.Duration(*hc.StartPeriod)
|
||||
}
|
||||
if hc.StartInterval != nil {
|
||||
spec.StartInterval = time.Duration(*hc.StartInterval)
|
||||
}
|
||||
if hc.Retries != nil {
|
||||
spec.Retries = uint(*hc.Retries)
|
||||
}
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
|
||||
resources := api.ContainerResources{
|
||||
CPU: int64(service.CPUS * 1e9),
|
||||
Memory: int64(service.MemLimit),
|
||||
MemoryReservation: int64(service.MemReservation),
|
||||
Ulimits: ulimitsFromCompose(service.Ulimits),
|
||||
}
|
||||
|
||||
// Convert device mappings, separating CDI devices from regular device mappings.
|
||||
// CDI devices are identified when Source == Target and the source is a qualified CDI name.
|
||||
var cdiDeviceNames []string
|
||||
for _, dev := range service.Devices {
|
||||
if dev.Source == dev.Target && cdi.IsQualifiedName(dev.Source) {
|
||||
cdiDeviceNames = append(cdiDeviceNames, dev.Source)
|
||||
continue
|
||||
}
|
||||
resources.Devices = append(resources.Devices, api.DeviceMapping{
|
||||
HostPath: dev.Source,
|
||||
ContainerPath: dev.Target,
|
||||
CgroupPermissions: dev.Permissions,
|
||||
})
|
||||
}
|
||||
if len(cdiDeviceNames) > 0 {
|
||||
resources.DeviceReservations = append(resources.DeviceReservations, container.DeviceRequest{
|
||||
Driver: "cdi",
|
||||
DeviceIDs: cdiDeviceNames,
|
||||
})
|
||||
}
|
||||
|
||||
// Convert GPU device requests from compose format, appending "gpu" capability.
|
||||
@@ -293,6 +362,33 @@ func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.Vol
|
||||
return spec
|
||||
}
|
||||
|
||||
func ulimitsFromCompose(ulimits map[string]*types.UlimitsConfig) map[string]api.Ulimit {
|
||||
if len(ulimits) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
res := make(map[string]api.Ulimit, len(ulimits))
|
||||
for name, u := range ulimits {
|
||||
soft := u.Soft
|
||||
hard := u.Hard
|
||||
if u.Single != 0 {
|
||||
if soft == 0 {
|
||||
soft = u.Single
|
||||
}
|
||||
if hard == 0 {
|
||||
hard = u.Single
|
||||
}
|
||||
}
|
||||
|
||||
res[name] = api.Ulimit{
|
||||
Soft: int64(soft),
|
||||
Hard: int64(hard),
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// validateServicesExtensions validates extension combinations across all services in the project.
|
||||
func validateServicesExtensions(project *types.Project) error {
|
||||
for _, service := range project.Services {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||
"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",
|
||||
want: map[string]api.ServiceSpec{
|
||||
"test": {
|
||||
@@ -109,6 +110,14 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
"EMPTY": "",
|
||||
"VAR": "value",
|
||||
},
|
||||
Healthcheck: &api.HealthcheckSpec{
|
||||
Test: []string{"CMD", "curl", "-f", "http://localhost"},
|
||||
Interval: 1*time.Minute + 30*time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
Retries: 5,
|
||||
StartPeriod: 15 * time.Second,
|
||||
StartInterval: 2 * time.Second,
|
||||
},
|
||||
Image: "nginx:latest",
|
||||
Init: &initTrue,
|
||||
LogDriver: &api.LogDriver{
|
||||
@@ -124,6 +133,17 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
CPU: 0.5 * api.Core,
|
||||
Memory: 100 * units.MiB,
|
||||
MemoryReservation: 50 * units.MiB,
|
||||
Ulimits: map[string]api.Ulimit{
|
||||
"nofile": {Soft: 20000, Hard: 40000},
|
||||
"nproc": {Soft: 65535, Hard: 65535},
|
||||
},
|
||||
Devices: []api.DeviceMapping{
|
||||
{HostPath: "/dev/ttyUSB0", ContainerPath: "/dev/ttyUSB0", CgroupPermissions: "rw"},
|
||||
{HostPath: "/dev/sda", ContainerPath: "/dev/xvda", CgroupPermissions: "rwm"},
|
||||
},
|
||||
DeviceReservations: []container.DeviceRequest{
|
||||
{Count: -1, Capabilities: [][]string{{"gpu"}}},
|
||||
},
|
||||
},
|
||||
Sysctls: map[string]string{
|
||||
"net.ipv4.ip_forward": "1",
|
||||
@@ -139,6 +159,15 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
VolumeName: "data1",
|
||||
ContainerPath: "/data1",
|
||||
},
|
||||
{
|
||||
VolumeName: "bind-53f1acbf1de61e9e608c93effca23791674e463d02bb7aaca7c625804aef1926",
|
||||
ContainerPath: "/path/in/container",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
VolumeName: "data3-labeled",
|
||||
ContainerPath: "/data3",
|
||||
},
|
||||
{
|
||||
VolumeName: "data2-alias",
|
||||
ContainerPath: "/data2/long/syntax",
|
||||
@@ -173,8 +202,24 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
},
|
||||
Placement: api.Placement{
|
||||
Machines: []string{"machine-1", "machine-2"},
|
||||
},
|
||||
Replicas: 3,
|
||||
UpdateConfig: api.UpdateConfig{
|
||||
Order: api.UpdateOrderStopFirst,
|
||||
MonitorPeriod: &api.DefaultHealthMonitorPeriod,
|
||||
},
|
||||
Volumes: []api.VolumeSpec{
|
||||
{
|
||||
Name: "bind-53f1acbf1de61e9e608c93effca23791674e463d02bb7aaca7c625804aef1926",
|
||||
Type: api.VolumeTypeBind,
|
||||
BindOptions: &api.BindOptions{
|
||||
HostPath: "/path/on/host",
|
||||
CreateHostPath: true,
|
||||
Propagation: mount.Propagation("rprivate"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90",
|
||||
Type: api.VolumeTypeBind,
|
||||
@@ -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",
|
||||
Type: api.VolumeTypeTmpfs,
|
||||
TmpfsOptions: &mount.TmpfsOptions{
|
||||
SizeBytes: 10 * units.MiB,
|
||||
Mode: os.FileMode(1770),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -257,6 +313,8 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_Caddy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
composeYAML string
|
||||
@@ -405,6 +463,8 @@ services:
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_GPUs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
composeYAML string
|
||||
@@ -592,6 +652,8 @@ services:
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_VolumeDriverOpts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name 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) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name 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
|
||||
command: ["nginx", "updated", "command"]
|
||||
cpus: 0.5
|
||||
deploy:
|
||||
update_config:
|
||||
order: stop-first
|
||||
devices:
|
||||
- /dev/ttyUSB0:/dev/ttyUSB0:rw
|
||||
- /dev/sda:/dev/xvda
|
||||
entrypoint: ["/updated-docker-entrypoint.sh"]
|
||||
environment:
|
||||
BOOL: "true"
|
||||
EMPTY: ""
|
||||
VAR: value
|
||||
gpus: all
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost"]
|
||||
interval: 1m30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
start_interval: 2s
|
||||
image: nginx:latest
|
||||
init: true
|
||||
logging:
|
||||
@@ -25,10 +39,28 @@ services:
|
||||
scale: 3
|
||||
sysctls:
|
||||
- net.ipv4.ip_forward=1
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 20000
|
||||
hard: 40000
|
||||
nproc: 65535
|
||||
user: nginx:nginx
|
||||
volumes:
|
||||
- /etc/passwd:/host/etc/passwd:ro
|
||||
- data1:/data1
|
||||
- type: bind
|
||||
source: /path/on/host
|
||||
target: /path/in/container
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: true
|
||||
propagation: rprivate
|
||||
- type: volume
|
||||
source: data3-labeled
|
||||
target: /data3
|
||||
volume:
|
||||
nocopy: true
|
||||
subpath: app/data
|
||||
- type: volume
|
||||
source: data2-alias
|
||||
target: /data2/long/syntax
|
||||
@@ -37,6 +69,8 @@ services:
|
||||
target: /tmpfs
|
||||
tmpfs:
|
||||
size: 10485760
|
||||
mode: 1770
|
||||
x-machines: ["machine-1", "machine-2"]
|
||||
x-ports:
|
||||
- test.example.com:80/https
|
||||
- 8000/http
|
||||
@@ -55,5 +89,8 @@ volumes:
|
||||
data2-alias:
|
||||
name: data2
|
||||
driver: local
|
||||
data3-labeled:
|
||||
labels:
|
||||
env: test
|
||||
data-external:
|
||||
external: true
|
||||
|
||||
@@ -24,6 +24,16 @@ type SSHConnectorConfig struct {
|
||||
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.
|
||||
type SSHConnector struct {
|
||||
config SSHConnectorConfig
|
||||
@@ -48,7 +58,11 @@ func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
||||
var err error
|
||||
c.client, err = sshexec.Connect(c.config.User, c.config.Host, c.config.Port, c.config.KeyPath)
|
||||
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"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/docker/cli/cli/connhelper/commandconn"
|
||||
@@ -17,101 +19,96 @@ import (
|
||||
// and running `uncloudd dial-stdio` on the remote machine.
|
||||
type SSHCLIConnector struct {
|
||||
config SSHConnectorConfig
|
||||
conn net.Conn
|
||||
// Path to SSH control socket for connection reuse.
|
||||
controlSockPath string
|
||||
}
|
||||
|
||||
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.
|
||||
type sshCLIDialer struct {
|
||||
config SSHConnectorConfig
|
||||
}
|
||||
// controlSocketPath returns a unique control socket path for the SSH connection.
|
||||
// Returns an empty string if unable to find or create a suitable path.
|
||||
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.
|
||||
func (d *sshCLIDialer) buildDialArgs(address string) []string {
|
||||
args := []string{}
|
||||
|
||||
// Add connection timeout to fail fast when node is down.
|
||||
args = append(args, "-o", "ConnectTimeout=5")
|
||||
|
||||
// Add port if non-standard.
|
||||
if d.config.Port != 0 && d.config.Port != 22 {
|
||||
args = append(args, "-p", strconv.Itoa(d.config.Port))
|
||||
// Prefer XDG_RUNTIME_DIR if set, fall back to ~/.ssh if it exists.
|
||||
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
|
||||
return filepath.Join(dir, sockName)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
if fi, sErr := os.Stat(sshDir); sErr == nil && fi.IsDir() {
|
||||
return filepath.Join(sshDir, sockName)
|
||||
}
|
||||
}
|
||||
|
||||
// Add identity file if specified.
|
||||
if d.config.KeyPath != "" {
|
||||
args = append(args, "-i", d.config.KeyPath)
|
||||
// Last resort: create a subdirectory in temp with restricted permissions.
|
||||
tmpDir := filepath.Join(os.TempDir(), fmt.Sprintf("uncloud-%d", os.Getuid()))
|
||||
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.
|
||||
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
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
||||
// Build SSH command arguments.
|
||||
args := c.buildSSHArgs()
|
||||
|
||||
// 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.
|
||||
// Create gRPC client with a dialer that spawns a new SSH connection on demand.
|
||||
// Each dial attempt runs `ssh ... uncloudd dial-stdio`, reusing the control socket if available.
|
||||
grpcConn, err := grpc.NewClient(
|
||||
"passthrough:///", // Dummy target since we're using a custom dialer.
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultServiceConfig(defaultServiceConfig),
|
||||
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 {
|
||||
c.conn.Close()
|
||||
return nil, fmt.Errorf("create machine API client: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
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.
|
||||
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.
|
||||
if c.config.Port != 0 && c.config.Port != 22 {
|
||||
// Add port if specified.
|
||||
if c.config.Port != 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
// Add user@host.
|
||||
args = append(args, c.config.User+"@"+c.config.Host)
|
||||
// Add [user@]host destination.
|
||||
args = append(args, c.config.Destination())
|
||||
|
||||
// Add remote command: uncloudd dial-stdio
|
||||
args = append(args, "uncloudd", "dial-stdio")
|
||||
@@ -141,15 +138,74 @@ func (c *SSHCLIConnector) Dialer() (proxy.ContextDialer, error) {
|
||||
}
|
||||
|
||||
return &sshCLIDialer{
|
||||
config: c.config,
|
||||
config: c.config,
|
||||
controlSockPath: c.controlSockPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *SSHCLIConnector) Close() error {
|
||||
if c.conn != nil {
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
return err
|
||||
}
|
||||
// Individual connections are managed by gRPC and closed when the gRPC connection closes.
|
||||
// The SSH control socket may persist for connection reuse across CLI invocations.
|
||||
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
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
@@ -11,17 +12,28 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config SSHConnectorConfig
|
||||
expected []string
|
||||
name string
|
||||
config SSHConnectorConfig
|
||||
controlSockPath string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "basic connection",
|
||||
name: "basic connection with control socket",
|
||||
config: SSHConnectorConfig{
|
||||
User: "root",
|
||||
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",
|
||||
@@ -30,7 +42,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
Host: "example.com",
|
||||
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",
|
||||
@@ -39,7 +52,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
Host: "example.com",
|
||||
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",
|
||||
@@ -48,7 +62,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
Host: "example.com",
|
||||
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)",
|
||||
@@ -57,7 +72,8 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
Host: "example.com",
|
||||
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",
|
||||
@@ -68,16 +84,28 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
KeyPath: "/path/to/key",
|
||||
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{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
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.Parallel()
|
||||
|
||||
c := &SSHCLIConnector{config: tt.config}
|
||||
c := &SSHCLIConnector{config: tt.config, controlSockPath: tt.controlSockPath}
|
||||
got := c.buildSSHArgs()
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
@@ -96,61 +124,77 @@ func TestSSHCLIDialer_buildDialArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config SSHConnectorConfig
|
||||
address string
|
||||
expected []string
|
||||
name string
|
||||
config SSHConnectorConfig
|
||||
controlSockPath string
|
||||
address string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "basic connection",
|
||||
name: "basic connection without control socket",
|
||||
config: SSHConnectorConfig{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
},
|
||||
address: "10.210.1.1:5000",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-W", "10.210.1.1:5000", "root@example.com"},
|
||||
controlSockPath: "",
|
||||
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{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
Port: 2222,
|
||||
},
|
||||
address: "10.210.1.1:5000",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-W", "10.210.1.1:5000", "root@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", "-p", "2222", "-W", "10.210.1.1:5000", "root@example.com"},
|
||||
},
|
||||
{
|
||||
name: "with identity file",
|
||||
name: "with identity file and control socket",
|
||||
config: SSHConnectorConfig{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
Port: 22,
|
||||
KeyPath: "/home/user/.ssh/id_rsa",
|
||||
},
|
||||
address: "10.210.1.1:5000",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@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", "-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{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
Port: 2222,
|
||||
KeyPath: "/home/user/.ssh/id_rsa",
|
||||
},
|
||||
address: "10.210.1.1:5000",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@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", "-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{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
Port: 0,
|
||||
},
|
||||
address: "10.210.1.1:5000",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-W", "10.210.1.1:5000", "root@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"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -158,9 +202,28 @@ func TestSSHCLIDialer_buildDialArgs(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := &sshCLIDialer{config: tt.config}
|
||||
d := &sshCLIDialer{config: tt.config, controlSockPath: tt.controlSockPath}
|
||||
got := d.buildDialArgs(tt.address)
|
||||
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"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
@@ -17,6 +18,9 @@ import (
|
||||
"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.
|
||||
func (cli *Client) CreateContainer(
|
||||
ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string,
|
||||
@@ -354,3 +358,150 @@ func (cli *Client) ExecContainer(
|
||||
|
||||
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) {
|
||||
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.
|
||||
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) {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||
)
|
||||
|
||||
@@ -33,7 +34,7 @@ type Deployment struct {
|
||||
type Plan struct {
|
||||
ServiceID string
|
||||
ServiceName string
|
||||
SequenceOperation
|
||||
operation.SequenceOperation
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
// If the volume is located on multiple machines, services can be placed on any of them.
|
||||
// - Services must respect their individual placement constraints.
|
||||
@@ -128,12 +129,20 @@ func (s *VolumeScheduler) Schedule() (map[string][]api.VolumeSpec, error) {
|
||||
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.
|
||||
// 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.
|
||||
quotedServiceVolumes := make(map[string][]string)
|
||||
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] {
|
||||
quotedServiceVolumes[serviceName] = append(quotedServiceVolumes[serviceName],
|
||||
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
|
||||
// for missing volumes.
|
||||
// Skip constraints propagation for:
|
||||
// 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{})
|
||||
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 {
|
||||
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)
|
||||
for missingVolumeName, missingVolumeSpec := range s.volumeSpecs {
|
||||
// Skip volumes that already exist on machines.
|
||||
if _, ok := s.existingVolumeMachines[missingVolumeName]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
serviceNames := s.volumeServices[missingVolumeName]
|
||||
for volumeName, volumeSpec := range s.volumeSpecs {
|
||||
existingMachines := s.existingVolumeMachines[volumeName]
|
||||
serviceNames := s.volumeServices[volumeName]
|
||||
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).
|
||||
eligibleMachines := serviceEligibleMachines[serviceNames[0]]
|
||||
// Get the eligible machines for this volume.
|
||||
// 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 {
|
||||
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.
|
||||
sortedEligibleMachines := eligibleMachines.ToSlice()
|
||||
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 err := s.propagateConstraintsUntilConvergence(serviceEligibleMachines, placedVolumes); err != nil {
|
||||
return nil, fmt.Errorf("unexpected error while propagating constraints after "+
|
||||
"scheduling volume '%s' on machine '%s': %w", missingVolumeName, machineID, err)
|
||||
if s.isVolumeForGlobalService(volumeName) {
|
||||
// Global service: schedule volume on eligible machines that don't already have it.
|
||||
for _, machineID := range sortedEligibleMachines {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
+116
-62
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"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,
|
||||
// regardless of whether their specifications have changed.
|
||||
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 *scheduler.ClusterState
|
||||
@@ -144,10 +147,11 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
||||
|
||||
if len(containers) == 0 {
|
||||
// No more existing containers on this machine, create a new one.
|
||||
plan.Operations = append(plan.Operations, &RunContainerOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
Spec: spec,
|
||||
MachineID: m.Id,
|
||||
plan.Operations = append(plan.Operations, &operation.RunContainerOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
Spec: spec,
|
||||
MachineID: m.Id,
|
||||
SkipHealthMonitor: s.SkipHealthMonitor,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -160,36 +164,24 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
||||
continue
|
||||
}
|
||||
// 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.
|
||||
plan.Operations = append(plan.Operations, &RunContainerOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
Spec: spec,
|
||||
MachineID: m.Id,
|
||||
})
|
||||
|
||||
// Remove the old container.
|
||||
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
||||
MachineID: m.Id,
|
||||
Container: ctr,
|
||||
// Replace the old container with a new one.
|
||||
order := determineUpdateOrder(ctr, spec)
|
||||
plan.Operations = append(plan.Operations, &operation.ReplaceContainerOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
Spec: spec,
|
||||
MachineID: m.Id,
|
||||
OldContainer: ctr,
|
||||
Order: order,
|
||||
SkipHealthMonitor: s.SkipHealthMonitor,
|
||||
})
|
||||
}
|
||||
|
||||
// Remove any remaining containers that are not needed.
|
||||
for mid, containers := range containersOnMachine {
|
||||
for _, c := range containers {
|
||||
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
||||
plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
|
||||
MachineID: mid,
|
||||
Container: c,
|
||||
})
|
||||
@@ -228,7 +220,8 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
|
||||
|
||||
for _, m := range availableMachines {
|
||||
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 {
|
||||
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.
|
||||
for _, containers := range containersOnMachine {
|
||||
for _, c := range containers {
|
||||
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
||||
plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
|
||||
MachineID: c.MachineID,
|
||||
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
|
||||
// removing old ones. If there is a host port conflict, it stops the old container before starting a new one.
|
||||
func reconcileGlobalContainer(
|
||||
containers []api.MachineServiceContainer, spec api.ServiceSpec, serviceID, machineID string, forceRecreate bool,
|
||||
) ([]Operation, error) {
|
||||
var ops []Operation
|
||||
containers []api.MachineServiceContainer, spec api.ServiceSpec, serviceID, machineID string,
|
||||
forceRecreate, skipHealthCheck bool,
|
||||
) ([]operation.Operation, error) {
|
||||
var ops []operation.Operation
|
||||
|
||||
if len(containers) == 0 {
|
||||
// No containers on this machine, create a new one.
|
||||
ops = append(ops, &RunContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
Spec: spec,
|
||||
MachineID: machineID,
|
||||
ops = append(ops, &operation.RunContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
Spec: spec,
|
||||
MachineID: machineID,
|
||||
SkipHealthMonitor: skipHealthCheck,
|
||||
})
|
||||
return ops, nil
|
||||
}
|
||||
@@ -290,7 +285,7 @@ func reconcileGlobalContainer(
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
ops = append(ops, &RemoveContainerOperation{
|
||||
ops = append(ops, &operation.RemoveContainerOperation{
|
||||
MachineID: old.MachineID,
|
||||
Container: old.Container,
|
||||
})
|
||||
@@ -304,43 +299,102 @@ func reconcileGlobalContainer(
|
||||
}
|
||||
|
||||
// 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.
|
||||
for _, c := range containers {
|
||||
// Find the first running container to replace (there should typically be only one).
|
||||
var containerToReplace *api.MachineServiceContainer
|
||||
for i, c := range containers {
|
||||
if c.Container.State.Running {
|
||||
conflictingPorts, err := c.Container.ConflictingServicePorts(spec.Ports)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
}
|
||||
containerToReplace = &containers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Run a new container.
|
||||
ops = append(ops, &RunContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
Spec: spec,
|
||||
MachineID: machineID,
|
||||
})
|
||||
if containerToReplace != nil {
|
||||
// Stop any other running containers that have conflicting ports before replacing the container.
|
||||
// This handles the edge case where multiple running containers exist (due to bugs or interrupted deployments)
|
||||
// and more than one has ports that conflict with the new spec.
|
||||
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.
|
||||
for _, c := range containers {
|
||||
ops = append(ops, &RemoveContainerOperation{
|
||||
MachineID: c.MachineID,
|
||||
Container: c.Container,
|
||||
// Replace the running container with a new one.
|
||||
order := determineUpdateOrder(containerToReplace.Container, spec)
|
||||
ops = append(ops, &operation.ReplaceContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func newEmptyPlan(svc *api.Service, spec api.ServiceSpec) (Plan, error) {
|
||||
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"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -108,8 +110,7 @@ func verifyCaddyReachable(ctx context.Context, m *pb.MachineInfo) error {
|
||||
eventID := fmt.Sprintf("Machine %s (%s)", m.Name, publicIP)
|
||||
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)
|
||||
if err != nil {
|
||||
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.
|
||||
func unreachable(id string) progress.Event {
|
||||
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"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -363,14 +364,25 @@ func newUnregistryProxy(
|
||||
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) {
|
||||
info, err := dockerCli.Info(ctx)
|
||||
if err != nil {
|
||||
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 {
|
||||
if strings.Contains(strings.ToLower(info.Name), name) {
|
||||
return true, nil
|
||||
@@ -406,6 +418,9 @@ func runDockerVMProxyContainer(ctx context.Context, dockerCli *docker.Client, ta
|
||||
ExposedPorts: nat.PortSet{
|
||||
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.
|
||||
|
||||
+1
-1
@@ -338,7 +338,7 @@ if [ "$DOCKER_ALREADY_INSTALLED" = "true" ] && [ "$CONTAINERD_IMAGE_STORE_ENABLE
|
||||
echo ""
|
||||
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 "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 "makes image management more efficient."
|
||||
echo ""
|
||||
|
||||
@@ -58,6 +58,36 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api
|
||||
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.Init, ctr.HostConfig.Init)
|
||||
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")
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// Explicit short period as the default 5s is disabled in tests for faster test execution.
|
||||
monitorPeriod := 1 * time.Second
|
||||
spec := api.ServiceSpec{
|
||||
Mode: api.ServiceModeGlobal,
|
||||
Container: api.ContainerSpec{
|
||||
Image: "portainer/pause:latest",
|
||||
},
|
||||
UpdateConfig: api.UpdateConfig{
|
||||
MonitorPeriod: &monitorPeriod,
|
||||
},
|
||||
}
|
||||
deployment := cli.NewDeployment(spec, nil)
|
||||
|
||||
@@ -73,7 +78,9 @@ func TestDeployment(t *testing.T) {
|
||||
assert.NotEmpty(t, plan.ServiceName)
|
||||
assert.Len(t, plan.SequenceOperation.Operations, 3) // 3 run
|
||||
|
||||
start := time.Now()
|
||||
runPlan, err := deployment.Run(ctx)
|
||||
duration := time.Since(start)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, plan, runPlan)
|
||||
|
||||
@@ -88,6 +95,13 @@ func TestDeployment(t *testing.T) {
|
||||
machines := serviceMachines(svc)
|
||||
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.
|
||||
initialContainers := serviceContainerIDs(svc)
|
||||
|
||||
@@ -105,14 +119,19 @@ func TestDeployment(t *testing.T) {
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
},
|
||||
UpdateConfig: api.UpdateConfig{
|
||||
MonitorPeriod: &monitorPeriod,
|
||||
},
|
||||
}
|
||||
deployment = cli.NewDeployment(specWithPort, nil)
|
||||
|
||||
plan, err = deployment.Plan(ctx)
|
||||
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)
|
||||
duration = time.Since(start)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err = cli.InspectService(ctx, name)
|
||||
@@ -126,6 +145,13 @@ func TestDeployment(t *testing.T) {
|
||||
assert.Empty(t, initialContainers.Intersect(containers).ToSlice(),
|
||||
"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
|
||||
initialContainers = containers
|
||||
|
||||
@@ -150,7 +176,7 @@ func TestDeployment(t *testing.T) {
|
||||
|
||||
plan, err = deployment.Plan(ctx)
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
@@ -500,6 +526,8 @@ myapp.example.com {
|
||||
})
|
||||
|
||||
// 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{
|
||||
Name: name,
|
||||
Mode: api.ServiceModeReplicated,
|
||||
@@ -507,6 +535,9 @@ myapp.example.com {
|
||||
Image: "portainer/pause:latest",
|
||||
},
|
||||
Replicas: 2,
|
||||
UpdateConfig: api.UpdateConfig{
|
||||
MonitorPeriod: &monitorPeriod,
|
||||
},
|
||||
}
|
||||
deployment := cli.NewDeployment(spec, nil)
|
||||
|
||||
@@ -519,7 +550,9 @@ myapp.example.com {
|
||||
assert.Equal(t, name, plan.ServiceName)
|
||||
assert.Len(t, plan.SequenceOperation.Operations, 2) // 2 run operations for 2 replicas
|
||||
|
||||
start := time.Now()
|
||||
runPlan, err := deployment.Run(ctx)
|
||||
duration := time.Since(start)
|
||||
require.NoError(t, err)
|
||||
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")
|
||||
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.
|
||||
init := true
|
||||
updatedSpec := spec
|
||||
@@ -541,9 +581,11 @@ myapp.example.com {
|
||||
|
||||
plan, err = deployment.Plan(ctx)
|
||||
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)
|
||||
duration = time.Since(start)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err = cli.InspectService(ctx, name)
|
||||
@@ -558,6 +600,13 @@ myapp.example.com {
|
||||
assert.Empty(t, initialContainers.Intersect(containers).ToSlice(),
|
||||
"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.
|
||||
initialMachines = machines
|
||||
initialContainers = containers // Reset container tracking.
|
||||
@@ -593,7 +642,7 @@ myapp.example.com {
|
||||
|
||||
plan, err = deployment.Plan(ctx)
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
@@ -1213,6 +1262,9 @@ myapp.example.com {
|
||||
Container: api.ContainerSpec{
|
||||
Image: uniqueImage,
|
||||
},
|
||||
Placement: api.Placement{
|
||||
Machines: []string{c.Machines[0].Name, c.Machines[1].Name},
|
||||
},
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -1315,6 +1529,7 @@ func TestServiceLifecycle(t *testing.T) {
|
||||
Name: "container-spec-full",
|
||||
Mode: api.ServiceModeGlobal,
|
||||
Container: api.ContainerSpec{
|
||||
// TODO: Add the latest implemented fields to this spec and update assertContainerMatchesSpec.
|
||||
Command: []string{"sleep", "infinity"},
|
||||
// Extra slashes is not a typo, it changes the spec but Linux ignores them and uses the default /pause.
|
||||
Entrypoint: []string{"///pause"},
|
||||
@@ -1324,6 +1539,14 @@ func TestServiceLifecycle(t *testing.T) {
|
||||
"BOOL": "true",
|
||||
"": "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",
|
||||
Init: &init,
|
||||
LogDriver: &api.LogDriver{
|
||||
|
||||
@@ -20,3 +20,6 @@ services:
|
||||
log
|
||||
}
|
||||
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
|
||||
|
||||
- [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
|
||||
- [Compose support matrix](../../8-compose-file-reference/1-support-matrix.md): Supported Compose features and Uncloud
|
||||
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.
|
||||
The following table shows the support status for main Compose features:
|
||||
|
||||
| Feature | Support Status | Notes |
|
||||
|--------------------|--------------------|------------------------------------------------------------------------------------------------|
|
||||
| **Services** | | |
|
||||
| `build` | ✅ Supported | Build context and Dockerfile |
|
||||
| `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 |
|
||||
| `command` | ✅ Supported | Override container command |
|
||||
| `configs` | ✅ Supported | File-based and inline configs |
|
||||
| `cpus` | ✅ Supported | CPU limit |
|
||||
| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked |
|
||||
| `dns` | ❌ Not supported | Built-in service discovery |
|
||||
| `dns_search` | ❌ Not supported | Built-in service discovery |
|
||||
| `entrypoint` | ✅ Supported | Override container entrypoint |
|
||||
| `env_file` | ✅ Supported | Environment file |
|
||||
| `environment` | ✅ Supported | Environment variables |
|
||||
| `gpus` | ✅ Supported | GPU device access |
|
||||
| `image` | ✅ Supported | Container image specification |
|
||||
| `init` | ✅ Supported | Run init process in container |
|
||||
| `labels` | ❌ Not supported | |
|
||||
| `links` | ❌ Not supported | Use service names for communication |
|
||||
| `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver |
|
||||
| `mem_limit` | ✅ Supported | Memory limit |
|
||||
| `mem_reservation` | ✅ Supported | Memory reservation |
|
||||
| `mem_swappiness` | ❌ Not supported | |
|
||||
| `memswap_limit` | ❌ Not supported | |
|
||||
| `networks` | ❌ Not supported | All containers share cluster network |
|
||||
| `ports` | ⚠️ Limited | `mode: host` only, use `x-ports` for HTTP/HTTPS |
|
||||
| `privileged` | ✅ Supported | Run containers in privileged mode |
|
||||
| `pull_policy` | ✅ Supported | `always`, `missing`, `never` |
|
||||
| `secrets` | ❌ Not supported | Use configs or environment variables |
|
||||
| `security_opt` | ❌ Not supported | |
|
||||
| `storage_opt` | ❌ Not supported | |
|
||||
| `sysctls` | ✅ Supported | Namespaced kernel parameters |
|
||||
| `user` | ✅ Supported | Set container user |
|
||||
| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs |
|
||||
| **Deploy** | | |
|
||||
| `labels` | ❌ Not supported | |
|
||||
| `mode` | ✅ Supported | Either `global` or `replicated` |
|
||||
| `placement` | ❌ Not supported | Use `x-machines` extension |
|
||||
| `replicas` | ✅ Supported | Number of container replicas |
|
||||
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
|
||||
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |
|
||||
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
|
||||
| `update_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
|
||||
| **Volumes** | | |
|
||||
| Named volumes | ✅ Supported | Docker volumes |
|
||||
| Bind mounts | ✅ Supported | Host path binding |
|
||||
| Tmpfs mounts | ✅ Supported | In-memory filesystems |
|
||||
| Volume labels | ✅ Supported | Custom labels |
|
||||
| External volumes | ✅ Supported | Must exist before deployment |
|
||||
| Volume drivers | ⚠️ Limited | Local driver only |
|
||||
| **Configs** | | |
|
||||
| File-based configs | ✅ Supported | Read from file |
|
||||
| Inline configs | ✅ Supported | Defined in compose file |
|
||||
| External configs | ❌ Not supported | Not supported |
|
||||
| Short syntax | ❌ Not supported | Use long syntax only |
|
||||
| **Extensions** | | |
|
||||
| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration |
|
||||
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints |
|
||||
| `x-ports` | ✅ Uncloud-specific | Service port publishing |
|
||||
| Feature | Support Status | Notes |
|
||||
|--------------------|--------------------|----------------------------------------------------------------------------------------------------------------|
|
||||
| **Services** | | |
|
||||
| `build` | ✅ Supported | Build context and Dockerfile |
|
||||
| `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 |
|
||||
| `command` | ✅ Supported | Override container command |
|
||||
| `configs` | ✅ Supported | File-based and inline configs |
|
||||
| `cpus` | ✅ Supported | CPU limit |
|
||||
| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked |
|
||||
| `devices` | ✅ Supported | Device mappings |
|
||||
| `dns` | ❌ Not supported | Built-in service discovery |
|
||||
| `dns_search` | ❌ Not supported | Built-in service discovery |
|
||||
| `entrypoint` | ✅ Supported | Override container entrypoint |
|
||||
| `env_file` | ✅ Supported | Environment file |
|
||||
| `environment` | ✅ Supported | Environment variables |
|
||||
| `gpus` | ✅ Supported | GPU device access |
|
||||
| `healthcheck` | ✅ Supported | Health check configuration |
|
||||
| `image` | ✅ Supported | Container image specification |
|
||||
| `init` | ✅ Supported | Run init process in container |
|
||||
| `labels` | ❌ Not supported | |
|
||||
| `links` | ❌ Not supported | Use service names for communication |
|
||||
| `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver |
|
||||
| `mem_limit` | ✅ Supported | Memory limit |
|
||||
| `mem_reservation` | ✅ Supported | Memory reservation |
|
||||
| `mem_swappiness` | ❌ Not supported | |
|
||||
| `memswap_limit` | ❌ Not supported | |
|
||||
| `networks` | ❌ Not supported | All containers share cluster network |
|
||||
| `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](#x-ports) for HTTP/HTTPS |
|
||||
| `privileged` | ✅ Supported | Run containers in privileged mode |
|
||||
| `pull_policy` | ✅ Supported | `always`, `missing`, `never` |
|
||||
| `secrets` | ❌ Not supported | Use configs or environment variables |
|
||||
| `security_opt` | ❌ Not supported | |
|
||||
| `storage_opt` | ❌ Not supported | |
|
||||
| `sysctls` | ✅ Supported | Namespaced kernel parameters |
|
||||
| `user` | ✅ Supported | Set container user |
|
||||
| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs |
|
||||
| **Deploy** | | |
|
||||
| `labels` | ❌ Not supported | |
|
||||
| `mode` | ✅ Supported | Either `global` or `replicated` |
|
||||
| `placement` | ❌ Not supported | Use [`x-machines`](#x-machines) extension |
|
||||
| `replicas` | ✅ Supported | Number of container replicas |
|
||||
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
|
||||
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |
|
||||
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
|
||||
| `update_config` | ⚠️ Limited | `order` and `monitor` supported. See [rolling deployments](../4-guides/1-deployments/4-rolling-deployments.md) |
|
||||
| **Volumes** | | |
|
||||
| Named volumes | ✅ Supported | Docker volumes |
|
||||
| Bind mounts | ✅ Supported | Host path binding |
|
||||
| Tmpfs mounts | ✅ Supported | In-memory filesystems |
|
||||
| Volume labels | ✅ Supported | Custom labels |
|
||||
| External volumes | ✅ Supported | Must exist before deployment |
|
||||
| Volume drivers | ⚠️ Limited | Local driver only |
|
||||
| **Configs** | | |
|
||||
| File-based configs | ✅ Supported | Read from file |
|
||||
| Inline configs | ✅ Supported | Defined in compose file |
|
||||
| External configs | ❌ Not supported | Not supported |
|
||||
| Short syntax | ❌ Not supported | Use long syntax only |
|
||||
| **Extensions** | | |
|
||||
| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration |
|
||||
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints |
|
||||
| `x-ports` | ✅ Uncloud-specific | Service port publishing |
|
||||
|
||||
### 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
|
||||
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"
|
||||
@@ -92,8 +92,7 @@ image: myapp:{{date "20060102-150405" "Local"}} # → myapp:20251031-220651
|
||||
|
||||
### Date format reference
|
||||
|
||||
Go uses a reference time (`Mon Jan 2 15:04:05 MST 2006`) for formatting. Replace reference components with desired
|
||||
format:
|
||||
Go uses a reference time `Mon Jan 2 15:04:05 MST 2006` for formatting. Replace reference components with desired format:
|
||||
|
||||
| Component | Reference | Example |
|
||||
|-----------|-----------|-------------|
|
||||
|
||||
@@ -18,6 +18,8 @@ uc deploy [FLAGS] [SERVICE...] [flags]
|
||||
--no-cache Do not use cache when building images.
|
||||
-p, --profile strings One or more Compose profiles to enable.
|
||||
--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,
|
||||
e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]
|
||||
```
|
||||
|
||||
+15
-39
@@ -64,18 +64,22 @@
|
||||
<!-- Left side navigation -->
|
||||
<ul class="flex items-center">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<!-- Right side navigation -->
|
||||
<ul class="flex grow justify-end flex-wrap items-center">
|
||||
<ul class="flex grow justify-end items-center shrink-0">
|
||||
<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">
|
||||
<svg class="w-4 h-4 sm:mr-1 fill-current" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
@@ -85,7 +89,7 @@
|
||||
</a>
|
||||
</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">
|
||||
<svg class="w-4 h-4 sm:mr-1 fill-current" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
@@ -94,12 +98,6 @@
|
||||
<span class="ml-1 hidden sm:inline">GitHub</span>
|
||||
</a>
|
||||
</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>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -596,33 +594,13 @@
|
||||
</em>
|
||||
</h2>
|
||||
<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
|
||||
with you. Subscribe to follow the progress, get early insights into new features, and be the
|
||||
first to know when it's ready for production use. No marketing, ads or spam.
|
||||
<p>
|
||||
Subscribe to follow the progress and get early insights into new features.<br>
|
||||
See <a href="https://psviderski.substack.com/" target="_blank" class="text-zinc-900 hover:underline">previous newsletters</a>.
|
||||
</p>
|
||||
|
||||
<!-- Subscribe form -->
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-4 items-stretch text-zinc-900">
|
||||
<form action="https://app.kit.com/forms/7595321/subscriptions"
|
||||
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>
|
||||
<!-- Subscribe form -->
|
||||
<div class="flex justify-center">
|
||||
<iframe src="https://psviderski.substack.com/embed" width="480" height="150" style="border:1px solid #EEE; background:white;" frameborder="0" scrolling="no"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-sm text-zinc-500">
|
||||
@@ -754,8 +732,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://f.convertkit.com/ckjs/ck.5.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1316,10 +1316,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.min-w-0{
|
||||
min-width: 0px;
|
||||
}
|
||||
|
||||
.max-w-3xl{
|
||||
max-width: 48rem;
|
||||
}
|
||||
@@ -1336,10 +1332,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.max-w-sm{
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.max-w-xl{
|
||||
max-width: 36rem;
|
||||
}
|
||||
@@ -1348,10 +1340,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.flex-1{
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.shrink-0{
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1377,10 +1365,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-wrap{
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.items-start{
|
||||
align-items: flex-start;
|
||||
}
|
||||
@@ -1393,10 +1377,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.items-stretch{
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.justify-end{
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -1417,10 +1397,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gap-4{
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.gap-6{
|
||||
gap: 1.5rem;
|
||||
}
|
||||
@@ -1499,21 +1475,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
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-width: 1px;
|
||||
}
|
||||
@@ -1530,10 +1491,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
|
||||
.border-r-0{
|
||||
border-right-width: 0px;
|
||||
}
|
||||
|
||||
.border-t{
|
||||
border-top-width: 1px;
|
||||
}
|
||||
@@ -1664,16 +1621,16 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
padding-right: 0.125rem;
|
||||
}
|
||||
|
||||
.px-1{
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.px-2{
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.px-3{
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.px-4{
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
@@ -1838,11 +1795,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
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{
|
||||
color: transparent;
|
||||
}
|
||||
@@ -2115,11 +2067,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
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{
|
||||
--tw-bg-opacity: 1;
|
||||
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;
|
||||
}
|
||||
|
||||
.sm\:block{
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sm\:inline{
|
||||
display: inline;
|
||||
}
|
||||
@@ -2227,10 +2170,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sm\:flex-row{
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sm\:justify-center{
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -2255,9 +2194,9 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.sm\:px-3{
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
.sm\:px-5{
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.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-bottom: calc(0px * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.lg\:px-5{
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1536px){
|
||||
|
||||
Reference in New Issue
Block a user