mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5687cd713 | ||
|
|
a67f936cc1 | ||
|
|
51b8c29d63 | ||
|
|
e6f013dbc8 | ||
|
|
897f03aafd | ||
|
|
7b306f25ce | ||
|
|
78a3551c77 | ||
|
|
38c7dda6a0 | ||
|
|
51c7b5ba8f | ||
|
|
a507b202ec | ||
|
|
d9934b9dba | ||
|
|
1f328e99d5 | ||
|
|
3c435434a8 | ||
|
|
e54871758b | ||
|
|
c039390ade | ||
|
|
bad0f97b66 | ||
|
|
2405b156e4 | ||
|
|
4de17cb63c | ||
|
|
2222434934 | ||
|
|
3f1a772288 | ||
|
|
7b9bed0780 | ||
|
|
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,6 @@ node_modules/
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
.devcontainer/
|
||||
|
||||
.zed/
|
||||
|
||||
+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."
|
||||
@@ -73,14 +73,14 @@ mocks:
|
||||
.PHONY: test
|
||||
test:
|
||||
ifeq ($(TEST_NAME),)
|
||||
go test -count=1 -v ./...
|
||||
go test -shuffle=on -count=1 -v ./...
|
||||
else
|
||||
go test -race -count=1 -v -run ^$(TEST_NAME)$$ ./...
|
||||
go test -shuffle=on -race -count=1 -v -run ^$(TEST_NAME)$$ ./...
|
||||
endif
|
||||
|
||||
.PHONY: test-e2e
|
||||
test-e2e:
|
||||
go test -race -count=1 -v ./test/e2e
|
||||
go test -shuffle=on -race -count=1 -v ./test/e2e
|
||||
|
||||
.PHONY: test-clean
|
||||
test-clean:
|
||||
|
||||
@@ -61,7 +61,7 @@ Here is a more advanced use case. Deploy a highly available web app with automat
|
||||
on-premises in just a couple minutes.
|
||||
|
||||
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
|
||||
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
||||
<img src="https://embed-ssl.wistia.com/deliveries/4fb71ea442905b81b1ad4e25fc02132e.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
||||
</a>
|
||||
|
||||
<br>
|
||||
|
||||
@@ -60,7 +60,7 @@ func selectConnection(uncli *cli.CLI) error {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Default connection for context '%s' is now '%s'.\n", currentCtxName, selected)
|
||||
fmt.Printf("Default connection for context '%s' is now '%s'.\n", currentCtxName, selected.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -14,19 +14,21 @@ import (
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type addOptions struct {
|
||||
name string
|
||||
noCaddy bool
|
||||
noInstall bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
version string
|
||||
yes bool
|
||||
name string
|
||||
noCaddy bool
|
||||
noInstall bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
version string
|
||||
wgEndpoints []string
|
||||
yes bool
|
||||
}
|
||||
|
||||
func NewAddCommand() *cobra.Command {
|
||||
@@ -90,6 +92,14 @@ Connection methods:
|
||||
&opts.version, "version", "latest",
|
||||
"Version of the Uncloud daemon to install on the machine.",
|
||||
)
|
||||
cmd.Flags().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
||||
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
||||
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
||||
"If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
|
||||
)
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
|
||||
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
@@ -112,14 +122,24 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
||||
publicIP = &ip
|
||||
}
|
||||
|
||||
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
||||
addOpts := cli.AddMachineOptions{
|
||||
MachineName: opts.name,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
SkipInstall: opts.noInstall,
|
||||
Version: opts.version,
|
||||
AutoConfirm: opts.yes,
|
||||
})
|
||||
}
|
||||
if len(opts.wgEndpoints) > 0 {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
|
||||
}
|
||||
addOpts.WireguardEndpoints = endpoints
|
||||
}
|
||||
|
||||
clusterClient, machineClient, err := uncli.AddMachine(ctx, addOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type initOptions struct {
|
||||
context string
|
||||
dnsEndpoint string
|
||||
name string
|
||||
network string
|
||||
@@ -30,7 +32,7 @@ type initOptions struct {
|
||||
publicIP string
|
||||
sshKey string
|
||||
version string
|
||||
context string
|
||||
wgEndpoints []string
|
||||
yes bool
|
||||
}
|
||||
|
||||
@@ -130,6 +132,14 @@ Connection methods:
|
||||
&opts.version, "version", "latest",
|
||||
"Version of the Uncloud daemon to install on the machine.",
|
||||
)
|
||||
cmd.Flags().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
||||
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
||||
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
||||
"If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.",
|
||||
)
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
|
||||
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
@@ -161,7 +171,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
||||
}
|
||||
publicIP = &ip
|
||||
}
|
||||
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{
|
||||
initOpts := cli.InitClusterOptions{
|
||||
Context: opts.context,
|
||||
MachineName: opts.name,
|
||||
Network: netPrefix,
|
||||
@@ -170,7 +180,17 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
||||
SkipInstall: opts.noInstall,
|
||||
Version: opts.version,
|
||||
AutoConfirm: opts.yes,
|
||||
})
|
||||
}
|
||||
if len(opts.wgEndpoints) > 0 {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
|
||||
}
|
||||
initOpts.WireguardEndpoints = endpoints
|
||||
}
|
||||
|
||||
client, err := uncli.InitCluster(ctx, initOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ func NewRootCommand() *cobra.Command {
|
||||
NewRenameCommand(),
|
||||
NewRmCommand(),
|
||||
NewUpdateCommand(),
|
||||
NewTokenCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/daemon"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type tokenOptions struct {
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewTokenCommand() *cobra.Command {
|
||||
opts := tokenOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "token",
|
||||
Short: "Print the local machine's token for adding it to a cluster.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
token, err := daemon.MachineToken(opts.dataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get machine token: %w", err)
|
||||
}
|
||||
tokenStr, err := token.String()
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode machine token: %w", err)
|
||||
}
|
||||
fmt.Println(tokenStr)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.dataDir, "data-dir", "d", machine.DefaultDataDir,
|
||||
"Directory for storing persistent machine state.")
|
||||
_ = cmd.MarkFlagDirname("data-dir")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -4,29 +4,43 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type updateOptions struct {
|
||||
name string
|
||||
publicIP string
|
||||
name string
|
||||
publicIP string
|
||||
wgEndpoints []string
|
||||
}
|
||||
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
opts := updateOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Use: "update MACHINE [flags]",
|
||||
Short: "Update machine configuration in the cluster.",
|
||||
Long: `Update machine configuration in the cluster.
|
||||
|
||||
This command allows setting various machine properties including:
|
||||
- Machine name (--name)
|
||||
- Public IP address (--public-ip)
|
||||
Change the name, public IP address, or WireGuard endpoints of an existing machine.
|
||||
At least one flag must be specified to perform an update.`,
|
||||
Example: ` # Rename a machine.
|
||||
uc machine update machine1 --name web-server
|
||||
|
||||
At least one flag must be specified to perform an update operation.`,
|
||||
# Set the public IP address of a machine.
|
||||
uc machine update machine1 --public-ip 203.0.113.10
|
||||
|
||||
# Remove the public IP address from a machine.
|
||||
uc machine update machine1 --public-ip none
|
||||
|
||||
# Update WireGuard endpoints for a machine.
|
||||
uc machine update machine1 --wg-endpoint 203.0.113.10 --wg-endpoint 192.168.1.5
|
||||
|
||||
# Update multiple properties at once.
|
||||
uc machine update machine1 --name web-server --public-ip 203.0.113.10`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
@@ -40,16 +54,24 @@ At least one flag must be specified to perform an update operation.`,
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "",
|
||||
fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.", PublicIPNone),
|
||||
fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.",
|
||||
PublicIPNone),
|
||||
)
|
||||
cmd.Flags().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
fmt.Sprintf("WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. "+
|
||||
"Default port %d is used if omitted.\n", network.WireGuardPort)+
|
||||
"Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.\n"+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts updateOptions, machineNameOrID string) error {
|
||||
// Check if at least one flag was explicitly set
|
||||
if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") {
|
||||
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip)")
|
||||
// Check if at least one flag was explicitly set.
|
||||
if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") && !cmd.Flags().Changed("wg-endpoint") {
|
||||
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip, --wg-endpoint)")
|
||||
}
|
||||
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
@@ -87,6 +109,19 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and set endpoints if the flag was explicitly provided.
|
||||
if cmd.Flags().Changed("wg-endpoint") {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(endpoints) == 0 {
|
||||
return fmt.Errorf("at least one endpoint must be specified if --wg-endpoint flag is used")
|
||||
}
|
||||
req.Endpoints = endpoints
|
||||
}
|
||||
|
||||
// Perform the update operation
|
||||
updatedMachine, err := client.UpdateMachine(ctx, req)
|
||||
if err != nil {
|
||||
@@ -113,8 +148,24 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
|
||||
}
|
||||
changes = append(changes, fmt.Sprintf("public IP: %s -> %s", oldIP, newIP))
|
||||
}
|
||||
if cmd.Flags().Changed("wg-endpoint") {
|
||||
formatEndpoints := func(eps []*pb.IPPort) string {
|
||||
if len(eps) == 0 {
|
||||
return "none"
|
||||
}
|
||||
parts := make([]string, len(eps))
|
||||
for i, ep := range eps {
|
||||
ap, _ := ep.ToAddrPort()
|
||||
parts[i] = ap.String()
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
oldEndpoints := formatEndpoints(machine.Machine.Network.Endpoints)
|
||||
newEndpoints := formatEndpoints(updatedMachine.Network.Endpoints)
|
||||
changes = append(changes, fmt.Sprintf("endpoints: %s -> %s", oldEndpoints, newEndpoints))
|
||||
}
|
||||
|
||||
fmt.Printf("Machine %q (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id)
|
||||
fmt.Printf("Machine '%s' (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id)
|
||||
for _, change := range changes {
|
||||
fmt.Printf(" %s\n", change)
|
||||
}
|
||||
|
||||
@@ -99,15 +99,17 @@ If no services are specified, streams logs from all services defined in the Comp
|
||||
|
||||
func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logsOptions) error {
|
||||
// If no services specified, try to load them from the Compose file(s).
|
||||
fromCompose := false
|
||||
if len(serviceNames) == 0 {
|
||||
fromCompose = true
|
||||
project, err := compose.LoadProject(ctx, opts.files)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load compose file(s): %w", err)
|
||||
return fmt.Errorf("load Compose file(s): %w", err)
|
||||
}
|
||||
// View logs for all services, including disabled by inactive profiles.
|
||||
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...)
|
||||
if len(serviceNames) == 0 {
|
||||
return errors.New("no services found in compose file(s)")
|
||||
return errors.New("no services found in Compose file(s)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,20 +137,40 @@ func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts lo
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||
}
|
||||
|
||||
// Collect log streams from all services.
|
||||
// Collect log streams from all services. When service names come from a Compose file,
|
||||
// skip the ones that are not found in the cluster (they may have been removed or not deployed yet).
|
||||
machineIDsSet := mapset.NewSet[string]()
|
||||
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames))
|
||||
var foundServices, notFoundServices []string
|
||||
|
||||
for _, serviceName := range serviceNames {
|
||||
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNotFound) && fromCompose {
|
||||
notFoundServices = append(notFoundServices, serviceName)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err)
|
||||
}
|
||||
svcStreams = append(svcStreams, ch)
|
||||
foundServices = append(foundServices, serviceName)
|
||||
|
||||
machineIDs := svc.MachineIDs()
|
||||
machineIDsSet.Append(machineIDs...)
|
||||
}
|
||||
|
||||
if fromCompose {
|
||||
if len(foundServices) == 0 {
|
||||
return fmt.Errorf("stream logs for services defined in %s: no services found in the cluster",
|
||||
strings.Join(opts.files, ", "))
|
||||
}
|
||||
serviceNames = foundServices
|
||||
|
||||
for _, name := range notFoundServices {
|
||||
client.PrintWarning(fmt.Sprintf("service '%s' not found in the cluster, skipping", name))
|
||||
}
|
||||
}
|
||||
|
||||
var stream <-chan api.ServiceLogEntry
|
||||
if len(serviceNames) == 1 {
|
||||
stream = svcStreams[0]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+29
-20
@@ -155,14 +155,15 @@ func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, opts ConnectOptio
|
||||
}
|
||||
|
||||
type InitClusterOptions struct {
|
||||
Context string
|
||||
MachineName string
|
||||
Network netip.Prefix
|
||||
PublicIP *netip.Addr
|
||||
RemoteMachine *RemoteMachine
|
||||
SkipInstall bool
|
||||
Version string
|
||||
AutoConfirm bool
|
||||
Context string
|
||||
MachineName string
|
||||
Network netip.Prefix
|
||||
PublicIP *netip.Addr
|
||||
RemoteMachine *RemoteMachine
|
||||
SkipInstall bool
|
||||
Version string
|
||||
AutoConfirm bool
|
||||
WireguardEndpoints []*pb.IPPort
|
||||
}
|
||||
|
||||
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
|
||||
@@ -220,8 +221,9 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
||||
}
|
||||
|
||||
req := &pb.InitClusterRequest{
|
||||
MachineName: opts.MachineName,
|
||||
Network: pb.NewIPPrefix(opts.Network),
|
||||
MachineName: opts.MachineName,
|
||||
Network: pb.NewIPPrefix(opts.Network),
|
||||
WireguardEndpoints: opts.WireguardEndpoints,
|
||||
}
|
||||
if opts.PublicIP != nil {
|
||||
if opts.PublicIP.IsValid() {
|
||||
@@ -298,12 +300,13 @@ func (cli *CLI) newContextName(name string) (string, error) {
|
||||
}
|
||||
|
||||
type AddMachineOptions struct {
|
||||
MachineName string
|
||||
PublicIP *netip.Addr
|
||||
RemoteMachine *RemoteMachine
|
||||
SkipInstall bool
|
||||
Version string
|
||||
AutoConfirm bool
|
||||
MachineName string
|
||||
PublicIP *netip.Addr
|
||||
RemoteMachine *RemoteMachine
|
||||
SkipInstall bool
|
||||
Version string
|
||||
AutoConfirm bool
|
||||
WireguardEndpoints []*pb.IPPort
|
||||
}
|
||||
|
||||
// AddMachine provisions a remote machine and adds it to the cluster. It returns a cluster client and a machine client.
|
||||
@@ -380,10 +383,16 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
return nil, nil, fmt.Errorf("parse remote machine token: %w", err)
|
||||
}
|
||||
|
||||
// Register the machine in the cluster using its public key and endpoints from the token.
|
||||
endpoints := make([]*pb.IPPort, len(token.Endpoints))
|
||||
for i, addrPort := range token.Endpoints {
|
||||
endpoints[i] = pb.NewIPPort(addrPort)
|
||||
// Register the machine in the cluster using its public key and WireGuard endpoints from the token,
|
||||
// or use the explicitly provided WireGuard endpoints if any.
|
||||
var endpoints []*pb.IPPort
|
||||
if len(opts.WireguardEndpoints) > 0 {
|
||||
endpoints = opts.WireguardEndpoints
|
||||
} else {
|
||||
endpoints = make([]*pb.IPPort, len(token.Endpoints))
|
||||
for i, addrPort := range token.Endpoints {
|
||||
endpoints[i] = pb.NewIPPort(addrPort)
|
||||
}
|
||||
}
|
||||
addReq := &pb.AddMachineRequest{
|
||||
Name: opts.MachineName,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -34,3 +38,22 @@ func BindEnvToFlag(cmd *cobra.Command, flagName, envVar string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseWireGuardEndpoints parses a list of endpoint strings into a list of IPPort protobuf messages. Each value can
|
||||
// be an IP address, IP:PORT, IPv6, or [IPv6]:PORT. If the port is omitted, the default WireGuard port is used.
|
||||
func ParseWireGuardEndpoints(values []string) ([]*pb.IPPort, error) {
|
||||
endpoints := make([]*pb.IPPort, 0, len(values))
|
||||
for _, v := range values {
|
||||
ap, err := netip.ParseAddrPort(v)
|
||||
if err != nil {
|
||||
// Try parsing as a bare IP address and use the default WireGuard port.
|
||||
addr, addrErr := netip.ParseAddr(v)
|
||||
if addrErr != nil {
|
||||
return nil, fmt.Errorf("invalid endpoint '%s': must be IP, IPv6, IP:PORT, or [IPv6]:PORT", v)
|
||||
}
|
||||
ap = netip.AddrPortFrom(addr, network.WireGuardPort)
|
||||
}
|
||||
endpoints = append(endpoints, pb.NewIPPort(ap))
|
||||
}
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
)
|
||||
|
||||
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
|
||||
// TODO: ideally, this should be an RPC call to the daemon API to ensure the config is created and up-to-date.
|
||||
func MachineToken(dataDir string) (machine.Token, error) {
|
||||
state, err := machine.ParseState(machine.StatePath(dataDir))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return machine.Token{}, fmt.Errorf("load machine config (is uncloudd daemon running?): %w", err)
|
||||
}
|
||||
return machine.Token{}, fmt.Errorf("load machine config: %w", err)
|
||||
}
|
||||
if len(state.Network.PublicKey) == 0 {
|
||||
return machine.Token{}, errors.New("public key is not set in machine config")
|
||||
}
|
||||
|
||||
ips, err := network.ListRoutableIPs()
|
||||
if err != nil {
|
||||
return machine.Token{}, fmt.Errorf("list routable addresses: %w", err)
|
||||
}
|
||||
publicIP, err := network.GetPublicIP()
|
||||
// Ignore the error if failed to get the public IP using API services.
|
||||
if err == nil {
|
||||
ips = append(ips, publicIP)
|
||||
}
|
||||
|
||||
endpoints := make([]netip.AddrPort, len(ips))
|
||||
for i, ip := range ips {
|
||||
endpoints[i] = netip.AddrPortFrom(ip, network.WireGuardPort)
|
||||
}
|
||||
return machine.NewToken(state.Network.PublicKey, publicIP, endpoints), nil
|
||||
}
|
||||
@@ -233,6 +233,8 @@ type InitClusterRequest struct {
|
||||
// *InitClusterRequest_PublicIp
|
||||
// *InitClusterRequest_PublicIpAuto
|
||||
PublicIpConfig isInitClusterRequest_PublicIpConfig `protobuf_oneof:"public_ip_config"`
|
||||
// Optional WireGuard endpoints other machines will use to connect to this machine instead of auto-discovered ones.
|
||||
WireguardEndpoints []*IPPort `protobuf:"bytes,5,rep,name=wireguard_endpoints,json=wireguardEndpoints,proto3" json:"wireguard_endpoints,omitempty"`
|
||||
}
|
||||
|
||||
func (x *InitClusterRequest) Reset() {
|
||||
@@ -302,6 +304,13 @@ func (x *InitClusterRequest) GetPublicIpAuto() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *InitClusterRequest) GetWireguardEndpoints() []*IPPort {
|
||||
if x != nil {
|
||||
return x.WireguardEndpoints
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isInitClusterRequest_PublicIpConfig interface {
|
||||
isInitClusterRequest_PublicIpConfig()
|
||||
}
|
||||
@@ -1041,7 +1050,7 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73,
|
||||
0x66, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x61, 0x74, 0x69,
|
||||
0x73, 0x66, 0x69, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xc3, 0x01, 0x0a, 0x12,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x81, 0x02, 0x0a, 0x12,
|
||||
0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d,
|
||||
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65,
|
||||
@@ -1052,130 +1061,134 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
|
||||
0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x48, 0x00, 0x52, 0x08, 0x70, 0x75, 0x62,
|
||||
0x6c, 0x69, 0x63, 0x49, 0x70, 0x12, 0x26, 0x0a, 0x0e, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f,
|
||||
0x69, 0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52,
|
||||
0x0c, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x42, 0x12, 0x0a,
|
||||
0x10, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69,
|
||||
0x67, 0x22, 0x41, 0x0a, 0x13, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68,
|
||||
0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x0c, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x12, 0x3c, 0x0a,
|
||||
0x13, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f,
|
||||
0x69, 0x6e, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61,
|
||||
0x72, 0x64, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x42, 0x12, 0x0a, 0x10, 0x70,
|
||||
0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22,
|
||||
0x41, 0x0a, 0x13, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e,
|
||||
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61,
|
||||
0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69,
|
||||
0x6e, 0x65, 0x22, 0xaa, 0x01, 0x0a, 0x12, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74,
|
||||
0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63,
|
||||
0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61,
|
||||
0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x37, 0x0a, 0x0e, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x5f, 0x6d,
|
||||
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52,
|
||||
0x0d, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x2f,
|
||||
0x0a, 0x14, 0x6d, 0x69, 0x6e, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x64, 0x62, 0x5f, 0x76,
|
||||
0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x6d, 0x69,
|
||||
0x6e, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22,
|
||||
0x49, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e,
|
||||
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x08, 0x6d, 0x61, 0x63,
|
||||
0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73,
|
||||
0x52, 0x08, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x22, 0x91, 0x01, 0x0a, 0x0e, 0x4d,
|
||||
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x29, 0x0a,
|
||||
0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08,
|
||||
0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68,
|
||||
0x69, 0x6e, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||
0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63,
|
||||
0x68, 0x69, 0x6e, 0x65, 0x22, 0xaa, 0x01, 0x0a, 0x12, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75,
|
||||
0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x07, 0x6d,
|
||||
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61,
|
||||
0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07,
|
||||
0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x37, 0x0a, 0x0e, 0x6f, 0x74, 0x68, 0x65, 0x72,
|
||||
0x5f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
||||
0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66,
|
||||
0x6f, 0x52, 0x0d, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73,
|
||||
0x12, 0x2f, 0x0a, 0x14, 0x6d, 0x69, 0x6e, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x64, 0x62,
|
||||
0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11,
|
||||
0x6d, 0x69, 0x6e, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
|
||||
0x6e, 0x22, 0x49, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68,
|
||||
0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x08, 0x6d,
|
||||
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69,
|
||||
0x6c, 0x73, 0x52, 0x08, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x22, 0x91, 0x01, 0x0a,
|
||||
0x0e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12,
|
||||
0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
|
||||
0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61,
|
||||
0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d,
|
||||
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f,
|
||||
0x64, 0x62, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03,
|
||||
0x52, 0x0e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
|
||||
0x22, 0x25, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x0e, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x65, 0x74,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xc3, 0x01, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x36, 0x0a, 0x0a, 0x63,
|
||||
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
||||
0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x43, 0x6f,
|
||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e,
|
||||
0x65, 0x72, 0x73, 0x1a, 0x48, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
|
||||
0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x12,
|
||||
0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x0c, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x22, 0x27, 0x0a,
|
||||
0x15, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x40, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63,
|
||||
0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x26, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
|
||||
0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0xb2, 0x01, 0x0a, 0x1f, 0x49, 0x6e, 0x73,
|
||||
0x70, 0x65, 0x63, 0x74, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74,
|
||||
0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e,
|
||||
0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x4e,
|
||||
0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65,
|
||||
0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b,
|
||||
0x65, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72,
|
||||
0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50,
|
||||
0x6f, 0x72, 0x74, 0x12, 0x28, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03,
|
||||
0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61,
|
||||
0x72, 0x64, 0x50, 0x65, 0x65, 0x72, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x22, 0x83, 0x02,
|
||||
0x0a, 0x0d, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x50, 0x65, 0x65, 0x72, 0x12,
|
||||
0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x1a,
|
||||
0x0a, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x13, 0x6c, 0x61,
|
||||
0x73, 0x74, 0x5f, 0x68, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x5f, 0x74, 0x69, 0x6d,
|
||||
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
|
||||
0x61, 0x6d, 0x70, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61,
|
||||
0x6b, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76,
|
||||
0x65, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72,
|
||||
0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x74,
|
||||
0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x74, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x74, 0x42, 0x79, 0x74,
|
||||
0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x69, 0x70,
|
||||
0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64,
|
||||
0x49, 0x70, 0x73, 0x32, 0xe3, 0x04, 0x0a, 0x07, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12,
|
||||
0x4d, 0x0a, 0x12, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69,
|
||||
0x73, 0x69, 0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1f, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75,
|
||||
0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40,
|
||||
0x0a, 0x0b, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x17, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69,
|
||||
0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x3e, 0x0a, 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12,
|
||||
0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65,
|
||||
0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
|
||||
0x12, 0x33, 0x0a, 0x05, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
|
||||
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74,
|
||||
0x79, 0x1a, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x33, 0x0a, 0x07, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
|
||||
0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
|
||||
0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d,
|
||||
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x45, 0x0a, 0x0e, 0x49, 0x6e,
|
||||
0x73, 0x70, 0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x16, 0x2e, 0x67,
|
||||
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45,
|
||||
0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65,
|
||||
0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x57, 0x0a, 0x17, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x57, 0x69, 0x72, 0x65,
|
||||
0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x16, 0x2e, 0x67,
|
||||
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45,
|
||||
0x6d, 0x70, 0x74, 0x79, 0x1a, 0x24, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65,
|
||||
0x68, 0x69, 0x6e, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x64, 0x62,
|
||||
0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e,
|
||||
0x73, 0x74, 0x6f, 0x72, 0x65, 0x44, 0x62, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x25,
|
||||
0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
|
||||
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x0e, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x65, 0x74, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xc3, 0x01, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||
0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,
|
||||
0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x36, 0x0a, 0x0a, 0x63, 0x6f, 0x6e,
|
||||
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x6e, 0x74,
|
||||
0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72,
|
||||
0x73, 0x1a, 0x48, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1d,
|
||||
0x0a, 0x0a, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x09, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x12, 0x1c, 0x0a,
|
||||
0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c,
|
||||
0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x22, 0x27, 0x0a, 0x15, 0x49,
|
||||
0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x02, 0x69, 0x64, 0x22, 0x40, 0x0a, 0x16, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53,
|
||||
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26,
|
||||
0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x0c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x07, 0x73,
|
||||
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0xb2, 0x01, 0x0a, 0x1f, 0x49, 0x6e, 0x73, 0x70, 0x65,
|
||||
0x63, 0x74, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f,
|
||||
0x72, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x05, 0x52, 0x65,
|
||||
0x73, 0x65, 0x74, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x52,
|
||||
0x72, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e,
|
||||
0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d,
|
||||
0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79,
|
||||
0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72,
|
||||
0x74, 0x12, 0x28, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b,
|
||||
0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64,
|
||||
0x50, 0x65, 0x65, 0x72, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x22, 0x83, 0x02, 0x0a, 0x0d,
|
||||
0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x50, 0x65, 0x65, 0x72, 0x12, 0x1d, 0x0a,
|
||||
0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08,
|
||||
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
|
||||
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x13, 0x6c, 0x61, 0x73, 0x74,
|
||||
0x5f, 0x68, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
||||
0x70, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65,
|
||||
0x54, 0x69, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x5f,
|
||||
0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72, 0x65, 0x63,
|
||||
0x65, 0x69, 0x76, 0x65, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x72, 0x61,
|
||||
0x6e, 0x73, 0x6d, 0x69, 0x74, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x74, 0x42, 0x79, 0x74, 0x65, 0x73,
|
||||
0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x69, 0x70, 0x73, 0x18,
|
||||
0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70,
|
||||
0x73, 0x32, 0xe3, 0x04, 0x0a, 0x07, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x4d, 0x0a,
|
||||
0x12, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73, 0x69,
|
||||
0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1f, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73,
|
||||
0x69, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b,
|
||||
0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70,
|
||||
0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x43,
|
||||
0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e,
|
||||
0x0a, 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x17, 0x2e,
|
||||
0x61, 0x70, 0x69, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x49,
|
||||
0x0a, 0x0e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
|
||||
0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65,
|
||||
0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61,
|
||||
0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74,
|
||||
0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73,
|
||||
0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72,
|
||||
0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f,
|
||||
0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x33,
|
||||
0x0a, 0x05, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a,
|
||||
0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x33, 0x0a, 0x07, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x12, 0x16,
|
||||
0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
|
||||
0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63,
|
||||
0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x45, 0x0a, 0x0e, 0x49, 0x6e, 0x73, 0x70,
|
||||
0x65, 0x63, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f,
|
||||
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70,
|
||||
0x74, 0x79, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
|
||||
0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x57, 0x0a, 0x17, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x57, 0x69, 0x72, 0x65, 0x47, 0x75,
|
||||
0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f,
|
||||
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70,
|
||||
0x74, 0x79, 0x1a, 0x24, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
|
||||
0x57, 0x69, 0x72, 0x65, 0x47, 0x75, 0x61, 0x72, 0x64, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x05, 0x52, 0x65, 0x73, 0x65,
|
||||
0x74, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x49, 0x0a, 0x0e,
|
||||
0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x1a,
|
||||
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69,
|
||||
0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75,
|
||||
0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69,
|
||||
0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61,
|
||||
0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62,
|
||||
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -1223,39 +1236,40 @@ var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
|
||||
18, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort
|
||||
17, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix
|
||||
16, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP
|
||||
0, // 7: api.InitClusterResponse.machine:type_name -> api.MachineInfo
|
||||
0, // 8: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
|
||||
0, // 9: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
|
||||
7, // 10: api.InspectMachineResponse.machines:type_name -> api.MachineDetails
|
||||
19, // 11: api.MachineDetails.metadata:type_name -> api.Metadata
|
||||
0, // 12: api.MachineDetails.machine:type_name -> api.MachineInfo
|
||||
15, // 13: api.Service.containers:type_name -> api.Service.Container
|
||||
10, // 14: api.InspectServiceResponse.service:type_name -> api.Service
|
||||
14, // 15: api.InspectWireGuardNetworkResponse.peers:type_name -> api.WireGuardPeer
|
||||
20, // 16: api.WireGuardPeer.last_handshake_time:type_name -> google.protobuf.Timestamp
|
||||
21, // 17: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
|
||||
3, // 18: api.Machine.InitCluster:input_type -> api.InitClusterRequest
|
||||
5, // 19: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest
|
||||
21, // 20: api.Machine.Token:input_type -> google.protobuf.Empty
|
||||
21, // 21: api.Machine.Inspect:input_type -> google.protobuf.Empty
|
||||
21, // 22: api.Machine.InspectMachine:input_type -> google.protobuf.Empty
|
||||
21, // 23: api.Machine.InspectWireGuardNetwork:input_type -> google.protobuf.Empty
|
||||
9, // 24: api.Machine.Reset:input_type -> api.ResetRequest
|
||||
11, // 25: api.Machine.InspectService:input_type -> api.InspectServiceRequest
|
||||
2, // 26: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
|
||||
4, // 27: api.Machine.InitCluster:output_type -> api.InitClusterResponse
|
||||
21, // 28: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
|
||||
8, // 29: api.Machine.Token:output_type -> api.TokenResponse
|
||||
0, // 30: api.Machine.Inspect:output_type -> api.MachineInfo
|
||||
6, // 31: api.Machine.InspectMachine:output_type -> api.InspectMachineResponse
|
||||
13, // 32: api.Machine.InspectWireGuardNetwork:output_type -> api.InspectWireGuardNetworkResponse
|
||||
21, // 33: api.Machine.Reset:output_type -> google.protobuf.Empty
|
||||
12, // 34: api.Machine.InspectService:output_type -> api.InspectServiceResponse
|
||||
26, // [26:35] is the sub-list for method output_type
|
||||
17, // [17:26] is the sub-list for method input_type
|
||||
17, // [17:17] is the sub-list for extension type_name
|
||||
17, // [17:17] is the sub-list for extension extendee
|
||||
0, // [0:17] is the sub-list for field type_name
|
||||
18, // 7: api.InitClusterRequest.wireguard_endpoints:type_name -> api.IPPort
|
||||
0, // 8: api.InitClusterResponse.machine:type_name -> api.MachineInfo
|
||||
0, // 9: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
|
||||
0, // 10: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
|
||||
7, // 11: api.InspectMachineResponse.machines:type_name -> api.MachineDetails
|
||||
19, // 12: api.MachineDetails.metadata:type_name -> api.Metadata
|
||||
0, // 13: api.MachineDetails.machine:type_name -> api.MachineInfo
|
||||
15, // 14: api.Service.containers:type_name -> api.Service.Container
|
||||
10, // 15: api.InspectServiceResponse.service:type_name -> api.Service
|
||||
14, // 16: api.InspectWireGuardNetworkResponse.peers:type_name -> api.WireGuardPeer
|
||||
20, // 17: api.WireGuardPeer.last_handshake_time:type_name -> google.protobuf.Timestamp
|
||||
21, // 18: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
|
||||
3, // 19: api.Machine.InitCluster:input_type -> api.InitClusterRequest
|
||||
5, // 20: api.Machine.JoinCluster:input_type -> api.JoinClusterRequest
|
||||
21, // 21: api.Machine.Token:input_type -> google.protobuf.Empty
|
||||
21, // 22: api.Machine.Inspect:input_type -> google.protobuf.Empty
|
||||
21, // 23: api.Machine.InspectMachine:input_type -> google.protobuf.Empty
|
||||
21, // 24: api.Machine.InspectWireGuardNetwork:input_type -> google.protobuf.Empty
|
||||
9, // 25: api.Machine.Reset:input_type -> api.ResetRequest
|
||||
11, // 26: api.Machine.InspectService:input_type -> api.InspectServiceRequest
|
||||
2, // 27: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
|
||||
4, // 28: api.Machine.InitCluster:output_type -> api.InitClusterResponse
|
||||
21, // 29: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
|
||||
8, // 30: api.Machine.Token:output_type -> api.TokenResponse
|
||||
0, // 31: api.Machine.Inspect:output_type -> api.MachineInfo
|
||||
6, // 32: api.Machine.InspectMachine:output_type -> api.InspectMachineResponse
|
||||
13, // 33: api.Machine.InspectWireGuardNetwork:output_type -> api.InspectWireGuardNetworkResponse
|
||||
21, // 34: api.Machine.Reset:output_type -> google.protobuf.Empty
|
||||
12, // 35: api.Machine.InspectService:output_type -> api.InspectServiceResponse
|
||||
27, // [27:36] is the sub-list for method output_type
|
||||
18, // [18:27] is the sub-list for method input_type
|
||||
18, // [18:18] is the sub-list for extension type_name
|
||||
18, // [18:18] is the sub-list for extension extendee
|
||||
0, // [0:18] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_internal_machine_api_pb_machine_proto_init() }
|
||||
|
||||
@@ -55,6 +55,9 @@ message InitClusterRequest {
|
||||
IP public_ip = 3;
|
||||
bool public_ip_auto = 4;
|
||||
}
|
||||
|
||||
// Optional WireGuard endpoints other machines will use to connect to this machine instead of auto-discovered ones.
|
||||
repeated IPPort wireguard_endpoints = 5;
|
||||
}
|
||||
|
||||
message InitClusterResponse {
|
||||
|
||||
@@ -376,8 +376,6 @@ func (cc *clusterController) waitStoreSync(ctx context.Context) {
|
||||
}
|
||||
|
||||
if version >= minVersion {
|
||||
slog.Info("Cluster store completed the initial sync.", "version", version, "min_version", minVersion)
|
||||
|
||||
// Clear MinStoreDBVersion so next restart doesn't wait for sync.
|
||||
cc.state.mu.Lock()
|
||||
cc.state.MinStoreDBVersion = 0
|
||||
@@ -386,6 +384,25 @@ func (cc *clusterController) waitStoreSync(ctx context.Context) {
|
||||
}
|
||||
cc.state.mu.Unlock()
|
||||
|
||||
// Wait for all known missing changes to be synced before returning.
|
||||
// TODO: reevaluate if this is necessary after migrating to the latest Corrosion version:
|
||||
// https://github.com/psviderski/uncloud/issues/172
|
||||
// This works on the best effort basis as the missing changes may not be yet known when we start
|
||||
// checking it after reaching the minimum version.
|
||||
// Reaching the minimum version doesn't guarantee that the store is actually synced to
|
||||
// the state we observed on the source node. db_version is a machine-local Lamport clock.
|
||||
// When changes are received from a remote machine, the local db_version is set to
|
||||
// max(local_db_version, incoming_db_version) + 1 for each applied transaction. This means
|
||||
// the new machine's db_version can jump well past the source machine's db_version on the very
|
||||
// first batch of replicated changes, without having received all changes from all machines
|
||||
// in the cluster.
|
||||
cc.waitKnownMissingChanges(ctx)
|
||||
|
||||
if ver, verErr := cc.store.DBVersion(ctx); verErr == nil {
|
||||
version = ver
|
||||
}
|
||||
slog.Info("Cluster store completed the initial sync.", "version", version, "min_version", minVersion)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -399,6 +416,34 @@ func (cc *clusterController) waitStoreSync(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// waitKnownMissingChanges polls the store until all known missing changes have been synced.
|
||||
func (cc *clusterController) waitKnownMissingChanges(ctx context.Context) {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
changes, err := cc.store.KnownMissingChanges(ctx)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get known missing changes from the cluster store, skipping check.",
|
||||
"err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
slog.Debug("All known missing changes have been synced to the cluster store.")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("Waiting for known missing changes to be synced to the cluster store.", "remaining",
|
||||
len(changes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncDockerContainers watches local Docker containers and syncs them to the cluster store.
|
||||
// TODO: move this to the Docker controller.
|
||||
func (cc *clusterController) syncDockerContainers(ctx context.Context) error {
|
||||
@@ -458,7 +503,8 @@ func (cc *clusterController) handleMachineChanges(ctx context.Context) error {
|
||||
|
||||
// The machine store may be empty when a machine first joins the cluster, before store synchronization
|
||||
// completes. Skip configuration now and apply it when the store changes are received.
|
||||
// TODO: remove this check after releasing 0.17.0 and assuming cluster machines wait for store sync on join.
|
||||
// TODO: remove this check after ensuring the store is actually synced to the latest known state at this point.
|
||||
// See TODO in waitStoreSync.
|
||||
if len(machines) > 0 {
|
||||
slog.Info("Reconfiguring network peers with the current machines.", "machines", len(machines))
|
||||
if err = cc.configurePeers(machines); err != nil {
|
||||
|
||||
@@ -259,7 +259,7 @@ func (c *Cluster) UpdateMachine(ctx context.Context, req *pb.UpdateMachineReques
|
||||
updatedMachine.PublicIp = req.PublicIp
|
||||
}
|
||||
}
|
||||
if req.Endpoints != nil {
|
||||
if len(req.Endpoints) > 0 {
|
||||
updatedMachine.Network.Endpoints = req.Endpoints
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+22
-13
@@ -701,20 +701,29 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
|
||||
return nil, status.Errorf(codes.Internal, "generate machine name: %v", err)
|
||||
}
|
||||
}
|
||||
// Use the public and all routable IPs as endpoints.
|
||||
ips, err := network.ListRoutableIPs()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "list routable IPs: %v", err)
|
||||
}
|
||||
|
||||
// Use explicitly provided WireGuard endpoints, or use the routable IPs on the machine and its public IP
|
||||
// if not provided. The IPs are auto-detected.
|
||||
var endpoints []*pb.IPPort
|
||||
publicIP, pubIPErr := network.GetPublicIP()
|
||||
// Ignore the error if failed to get the public IP using API services.
|
||||
if pubIPErr == nil && !slices.Contains(ips, publicIP) {
|
||||
ips = append(ips, publicIP)
|
||||
}
|
||||
endpoints := make([]*pb.IPPort, len(ips))
|
||||
for i, addr := range ips {
|
||||
addrPort := netip.AddrPortFrom(addr, network.WireGuardPort)
|
||||
endpoints[i] = pb.NewIPPort(addrPort)
|
||||
|
||||
if len(req.WireguardEndpoints) > 0 {
|
||||
endpoints = req.WireguardEndpoints
|
||||
} else {
|
||||
ips, err := network.ListRoutableIPs()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "list routable IPs: %v", err)
|
||||
}
|
||||
// Ignore the error if failed to get the public IP using API services.
|
||||
if pubIPErr == nil && !slices.Contains(ips, publicIP) {
|
||||
ips = append(ips, publicIP)
|
||||
}
|
||||
|
||||
endpoints = make([]*pb.IPPort, len(ips))
|
||||
for i, addr := range ips {
|
||||
addrPort := netip.AddrPortFrom(addr, network.WireGuardPort)
|
||||
endpoints[i] = pb.NewIPPort(addrPort)
|
||||
}
|
||||
}
|
||||
|
||||
// Register the new machine in the cluster to populate the state and get its ID and subnet.
|
||||
|
||||
@@ -31,6 +31,12 @@ func ListRoutableIPs() ([]netip.Addr, error) {
|
||||
// Skip Docker bridge interfaces.
|
||||
continue
|
||||
}
|
||||
if iface.Name == "tailscale0" {
|
||||
// Skip Tailscale interface as we don't want double-tunneling. Also, MTU=1280 on tailscale0 is too small
|
||||
// for tunneling IPv6 used for uncloud management traffic.
|
||||
// See Discord thread: https://discord.com/channels/1371726032104587335/1477823130670993408
|
||||
continue
|
||||
}
|
||||
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagRunning == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
// Skip interfaces:
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -75,6 +76,35 @@ func (s *Store) DBVersion(ctx context.Context) (int64, error) {
|
||||
return version, nil
|
||||
}
|
||||
|
||||
type MissingChange struct {
|
||||
ActorID string
|
||||
StartVersion int64
|
||||
EndVersion int64
|
||||
}
|
||||
|
||||
// KnownMissingChanges returns a list of currently known missing changes in the Corrosion database.
|
||||
func (s *Store) KnownMissingChanges(ctx context.Context) ([]MissingChange, error) {
|
||||
rows, err := s.corro.QueryContext(ctx, "SELECT actor_id, start, end FROM __corro_bookkeeping_gaps")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query missing changes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var changes []MissingChange
|
||||
for rows.Next() {
|
||||
var c MissingChange
|
||||
var actorBytes []byte
|
||||
if err = rows.Scan(&actorBytes, &c.StartVersion, &c.EndVersion); err != nil {
|
||||
return nil, fmt.Errorf("scan missing change: %w", err)
|
||||
}
|
||||
|
||||
c.ActorID = hex.EncodeToString(actorBytes)
|
||||
changes = append(changes, c)
|
||||
}
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateMachine(ctx context.Context, m *pb.MachineInfo) error {
|
||||
mJSON, err := protojson.Marshal(m)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+74
-10
@@ -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
|
||||
@@ -47,6 +55,8 @@ type ServiceSpec struct {
|
||||
// Caddy is the optional Caddy reverse proxy configuration for the service.
|
||||
// Caddy and Ports cannot be specified simultaneously.
|
||||
Caddy *CaddySpec `json:",omitempty"`
|
||||
// Configs is list of configuration objects that can be mounted into the container.
|
||||
Configs []ConfigSpec
|
||||
// Container defines the desired state of each container in the service.
|
||||
Container ContainerSpec
|
||||
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
||||
@@ -59,10 +69,13 @@ 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"`
|
||||
// StopGracePeriod is how long to wait after SIGTERM before sending SIGKILL when stopping a container.
|
||||
// Default is 10 seconds if not specified.
|
||||
StopGracePeriod *time.Duration `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.
|
||||
Configs []ConfigSpec
|
||||
}
|
||||
|
||||
// CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined.
|
||||
@@ -234,8 +247,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 +346,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 +364,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 +384,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 +411,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 +438,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
|
||||
}
|
||||
|
||||
+113
-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,
|
||||
@@ -82,6 +85,11 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
||||
}
|
||||
}
|
||||
|
||||
if service.StopGracePeriod != nil {
|
||||
d := time.Duration(*service.StopGracePeriod)
|
||||
spec.StopGracePeriod = &d
|
||||
}
|
||||
|
||||
if service.Scale != nil {
|
||||
spec.Replicas = uint(*service.Scale)
|
||||
}
|
||||
@@ -97,6 +105,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 +144,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 +367,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,25 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
},
|
||||
Replicas: 3,
|
||||
Placement: api.Placement{
|
||||
Machines: []string{"machine-1", "machine-2"},
|
||||
},
|
||||
Replicas: 3,
|
||||
StopGracePeriod: api.AsPtr(30 * time.Second),
|
||||
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 +253,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 +314,8 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_Caddy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
composeYAML string
|
||||
@@ -405,6 +464,8 @@ services:
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_GPUs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
composeYAML string
|
||||
@@ -592,6 +653,8 @@ services:
|
||||
}
|
||||
|
||||
func TestServiceSpecFromCompose_VolumeDriverOpts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
composeYAML string
|
||||
@@ -699,7 +762,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 +1181,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:
|
||||
@@ -23,12 +37,31 @@ services:
|
||||
privileged: true
|
||||
pull_policy: always
|
||||
scale: 3
|
||||
stop_grace_period: 30s
|
||||
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 +70,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 +90,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,227 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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
|
||||
StopGracePeriod *time.Duration
|
||||
}
|
||||
|
||||
func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error {
|
||||
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, stopOptions(o.StopGracePeriod)); 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
|
||||
StopGracePeriod *time.Duration
|
||||
}
|
||||
|
||||
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) error {
|
||||
err := cli.StopContainer(ctx, o.Container.ServiceID(), o.Container.ID, stopOptions(o.StopGracePeriod))
|
||||
if 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
|
||||
StopGracePeriod *time.Duration
|
||||
}
|
||||
|
||||
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 {
|
||||
err = cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, stopOptions(o.StopGracePeriod))
|
||||
if 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, stopOptions(o.StopGracePeriod))
|
||||
|
||||
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, stopOptions(o.StopGracePeriod)); 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)
|
||||
}
|
||||
|
||||
// stopOptions converts a stop grace period duration to Docker container stop options.
|
||||
func stopOptions(gracePeriod *time.Duration) container.StopOptions {
|
||||
if gracePeriod == nil {
|
||||
return container.StopOptions{}
|
||||
}
|
||||
t := int(gracePeriod.Seconds())
|
||||
return container.StopOptions{Timeout: &t}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+130
-68
@@ -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,38 +164,28 @@ 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,
|
||||
StopGracePeriod: spec.StopGracePeriod,
|
||||
})
|
||||
}
|
||||
|
||||
// Remove any remaining containers that are not needed.
|
||||
for mid, containers := range containersOnMachine {
|
||||
for _, c := range containers {
|
||||
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
||||
MachineID: mid,
|
||||
Container: c,
|
||||
plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
|
||||
MachineID: mid,
|
||||
Container: c,
|
||||
StopGracePeriod: spec.StopGracePeriod,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -228,7 +222,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,9 +235,10 @@ 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{
|
||||
MachineID: c.MachineID,
|
||||
Container: c.Container,
|
||||
plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
|
||||
MachineID: c.MachineID,
|
||||
Container: c.Container,
|
||||
StopGracePeriod: spec.StopGracePeriod,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -254,16 +250,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,9 +288,10 @@ func reconcileGlobalContainer(
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
ops = append(ops, &RemoveContainerOperation{
|
||||
MachineID: old.MachineID,
|
||||
Container: old.Container,
|
||||
ops = append(ops, &operation.RemoveContainerOperation{
|
||||
MachineID: old.MachineID,
|
||||
Container: old.Container,
|
||||
StopGracePeriod: spec.StopGracePeriod,
|
||||
})
|
||||
}
|
||||
break
|
||||
@@ -304,43 +303,106 @@ 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,
|
||||
StopGracePeriod: spec.StopGracePeriod,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
StopGracePeriod: spec.StopGracePeriod,
|
||||
})
|
||||
|
||||
// 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,
|
||||
StopGracePeriod: spec.StopGracePeriod,
|
||||
})
|
||||
}
|
||||
} 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,
|
||||
StopGracePeriod: spec.StopGracePeriod,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+62
-15
@@ -5,7 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -73,24 +75,17 @@ func (cli *Client) CreateIngressRecords(ctx context.Context, serviceID string) (
|
||||
close(reachableMachines)
|
||||
}()
|
||||
|
||||
var ingressIPs []string
|
||||
var machines []*pb.MachineInfo
|
||||
for m := range reachableMachines {
|
||||
ip, _ := m.PublicIp.ToAddr()
|
||||
ingressIPs = append(ingressIPs, ip.String())
|
||||
machines = append(machines, m)
|
||||
}
|
||||
if len(ingressIPs) == 0 {
|
||||
if len(machines) == 0 {
|
||||
return nil, ErrNoReachableMachines
|
||||
}
|
||||
|
||||
req := &pb.CreateDomainRecordsRequest{
|
||||
Records: []*pb.DNSRecord{
|
||||
{
|
||||
Name: "*",
|
||||
Type: pb.DNSRecord_A,
|
||||
Values: ingressIPs,
|
||||
},
|
||||
// TODO: Add AAAA record with routable IPv6 addresses of machines running Caddy containers.
|
||||
},
|
||||
req, err := getCreateDomainRecordsRequest(machines)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create CreateDomainRecordsRequest: %w", err)
|
||||
}
|
||||
resp, err := cli.CreateDomainRecords(ctx, req)
|
||||
if err != nil {
|
||||
@@ -100,6 +95,54 @@ func (cli *Client) CreateIngressRecords(ctx context.Context, serviceID string) (
|
||||
return resp.Records, nil
|
||||
}
|
||||
|
||||
func getCreateDomainRecordsRequest(machines []*pb.MachineInfo) (*pb.CreateDomainRecordsRequest, error) {
|
||||
if len(machines) == 0 {
|
||||
return nil, fmt.Errorf("at least one machine must be provided")
|
||||
}
|
||||
|
||||
var ipv4IngressIPs []string
|
||||
var ipv6IngressIPs []string
|
||||
var errs error
|
||||
for _, m := range machines {
|
||||
ip, _ := m.PublicIp.ToAddr()
|
||||
if ip.Is4() {
|
||||
ipv4IngressIPs = append(ipv4IngressIPs, ip.String())
|
||||
} else if ip.Is6() {
|
||||
ipv6IngressIPs = append(ipv6IngressIPs, ip.String())
|
||||
} else {
|
||||
// This is just a save guard, in case some special case is ever missed.
|
||||
errs = errors.Join(errs, fmt.Errorf("machine with name %s (and ID: %s) has the public IP address '%s' which is neither IPv4 nor IPv6", m.Name, m.Id, ip.String()))
|
||||
}
|
||||
}
|
||||
if errs != nil {
|
||||
return nil, errs
|
||||
}
|
||||
|
||||
records := make([]*pb.DNSRecord, 0, 2)
|
||||
if len(ipv4IngressIPs) > 0 {
|
||||
records = append(records,
|
||||
&pb.DNSRecord{
|
||||
Name: "*",
|
||||
Type: pb.DNSRecord_A,
|
||||
Values: ipv4IngressIPs,
|
||||
},
|
||||
)
|
||||
}
|
||||
if len(ipv6IngressIPs) > 0 {
|
||||
records = append(records,
|
||||
&pb.DNSRecord{
|
||||
Name: "*",
|
||||
Type: pb.DNSRecord_AAAA,
|
||||
Values: ipv6IngressIPs,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return &pb.CreateDomainRecordsRequest{
|
||||
Records: records,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// verifyCaddyReachable verifies that the Caddy service is reachable on the machine by its public IP.
|
||||
func verifyCaddyReachable(ctx context.Context, m *pb.MachineInfo) error {
|
||||
publicIP, _ := m.PublicIp.ToAddr()
|
||||
@@ -108,8 +151,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 +212,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,104 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCreateDomainRecordsRequest(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
given []*pb.MachineInfo
|
||||
wantReq *pb.CreateDomainRecordsRequest
|
||||
wantErr error
|
||||
}{
|
||||
"single IPv4 machine": {
|
||||
given: []*pb.MachineInfo{
|
||||
{Id: "m1", Name: "machine-1", PublicIp: pb.NewIP(netip.MustParseAddr("1.2.3.4"))},
|
||||
},
|
||||
wantReq: &pb.CreateDomainRecordsRequest{
|
||||
Records: []*pb.DNSRecord{
|
||||
{Name: "*", Type: pb.DNSRecord_A, Values: []string{"1.2.3.4"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"single IPv6 machine": {
|
||||
given: []*pb.MachineInfo{
|
||||
{Id: "m1", Name: "machine-1", PublicIp: pb.NewIP(netip.MustParseAddr("2001:db8::1"))},
|
||||
},
|
||||
wantReq: &pb.CreateDomainRecordsRequest{
|
||||
Records: []*pb.DNSRecord{
|
||||
{Name: "*", Type: pb.DNSRecord_AAAA, Values: []string{"2001:db8::1"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"multiple IPv4 machines": {
|
||||
given: []*pb.MachineInfo{
|
||||
{Id: "m1", Name: "machine-1", PublicIp: pb.NewIP(netip.MustParseAddr("1.2.3.4"))},
|
||||
{Id: "m2", Name: "machine-2", PublicIp: pb.NewIP(netip.MustParseAddr("5.6.7.8"))},
|
||||
},
|
||||
wantReq: &pb.CreateDomainRecordsRequest{
|
||||
Records: []*pb.DNSRecord{
|
||||
{Name: "*", Type: pb.DNSRecord_A, Values: []string{"1.2.3.4", "5.6.7.8"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"mixed IPv4 and IPv6 machines": {
|
||||
given: []*pb.MachineInfo{
|
||||
{Id: "m1", Name: "machine-1", PublicIp: pb.NewIP(netip.MustParseAddr("1.2.3.4"))},
|
||||
{Id: "m2", Name: "machine-2", PublicIp: pb.NewIP(netip.MustParseAddr("2001:db8::1"))},
|
||||
{Id: "m3", Name: "machine-3", PublicIp: pb.NewIP(netip.MustParseAddr("10.0.0.1"))},
|
||||
{Id: "m4", Name: "machine-4", PublicIp: pb.NewIP(netip.MustParseAddr("2001:db8::2"))},
|
||||
},
|
||||
wantReq: &pb.CreateDomainRecordsRequest{
|
||||
Records: []*pb.DNSRecord{
|
||||
{Name: "*", Type: pb.DNSRecord_A, Values: []string{"1.2.3.4", "10.0.0.1"}},
|
||||
{Name: "*", Type: pb.DNSRecord_AAAA, Values: []string{"2001:db8::1", "2001:db8::2"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"empty machine list": {
|
||||
given: nil,
|
||||
wantErr: fmt.Errorf("at least one machine must be provided"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
gotReq, gotErr := getCreateDomainRecordsRequest(tt.given)
|
||||
if tt.wantErr == nil {
|
||||
require.NoError(t, gotErr)
|
||||
assert.Equal(t, tt.wantReq, gotReq)
|
||||
} else {
|
||||
assert.Nil(t, gotReq)
|
||||
assert.Equal(t, tt.wantErr.Error(), gotErr.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+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.
|
||||
|
||||
@@ -120,7 +120,7 @@ func (cli *Client) WaitMachineReady(ctx context.Context, timeout time.Duration)
|
||||
return backoff.Retry(inspect, boff)
|
||||
}
|
||||
|
||||
// WaitClusterReady waits for the connected machine to be ready to server cluster requests.
|
||||
// WaitClusterReady waits for the connected machine to be ready to serve cluster requests.
|
||||
func (cli *Client) WaitClusterReady(ctx context.Context, timeout time.Duration) error {
|
||||
// Backoff is not really needed here as the default service config for the gRPC client is already
|
||||
// doing retries with backoff for Unavailable errors. However, it's still convenient to use backoff
|
||||
|
||||
+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+"-"))
|
||||
|
||||
@@ -55,7 +55,7 @@ func createTestCluster(
|
||||
})
|
||||
|
||||
if waitReady {
|
||||
require.NoError(t, p.WaitClusterReady(ctx, c, 30*time.Second))
|
||||
require.NoError(t, p.WaitClusterReady(ctx, c, 60*time.Second))
|
||||
}
|
||||
|
||||
return c, p
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/ucind"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestInternalDNS tests the internal DNS functionality including the new machine-specific service lookups
|
||||
func TestInternalDNS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clusterName := "ucind-test.dns"
|
||||
ctx := context.Background()
|
||||
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 3}, true)
|
||||
|
||||
cli, err := c.Machines[0].Connect(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a test service with multiple replicas across machines
|
||||
serviceName := "test-dns-service"
|
||||
t.Cleanup(func() {
|
||||
err := cli.RemoveService(ctx, serviceName)
|
||||
if err != nil && !strings.Contains(err.Error(), "not found") {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
// Deploy a service across all machines in global mode using pause container
|
||||
spec := api.ServiceSpec{
|
||||
Name: serviceName,
|
||||
Mode: api.ServiceModeGlobal,
|
||||
Container: api.ContainerSpec{
|
||||
Image: "portainer/pause:latest",
|
||||
},
|
||||
}
|
||||
|
||||
deployment := cli.NewDeployment(spec, nil)
|
||||
_, err = deployment.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the service to be deployed
|
||||
var svc api.Service
|
||||
require.Eventually(t, func() bool {
|
||||
svc, err = cli.InspectService(ctx, serviceName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Should have 3 containers (one per machine) and all should be running
|
||||
if len(svc.Containers) != 3 {
|
||||
return false
|
||||
}
|
||||
for _, ctr := range svc.Containers {
|
||||
if ctr.Container.State.Status != "running" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, 30*time.Second, 1*time.Second, "Service should be deployed and running on all machines")
|
||||
|
||||
// Deploy a single "network-multitool" service to be used for all DNS queries
|
||||
queryServiceName := "dns-query-service"
|
||||
t.Cleanup(func() {
|
||||
err := cli.RemoveService(ctx, queryServiceName)
|
||||
if err != nil && !strings.Contains(err.Error(), "not found") {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
querySvcSpec := api.ServiceSpec{
|
||||
Name: queryServiceName,
|
||||
Mode: api.ServiceModeReplicated,
|
||||
Replicas: 1,
|
||||
Placement: api.Placement{
|
||||
Machines: []string{c.Machines[0].Name},
|
||||
},
|
||||
Container: api.ContainerSpec{
|
||||
Image: "wbitt/network-multitool",
|
||||
Command: []string{"sleep", "infinity"},
|
||||
},
|
||||
}
|
||||
_, err = cli.RunService(ctx, querySvcSpec)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the query service to be deployed
|
||||
var querySvc api.Service
|
||||
require.Eventually(t, func() bool {
|
||||
querySvc, err = cli.InspectService(ctx, queryServiceName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Should have 1 container and it should be running
|
||||
if len(querySvc.Containers) != 1 {
|
||||
return false
|
||||
}
|
||||
return querySvc.Containers[0].Container.State.Status == "running"
|
||||
}, 30*time.Second, 1*time.Second, "Query service should be deployed and running")
|
||||
queryContainer := querySvc.Containers[0]
|
||||
|
||||
// Run nslookup for given query
|
||||
runNslookup := func(t *testing.T, dnsQuery string) string {
|
||||
dnsOutput, err := execInContainerAndReadOutput(
|
||||
t, ctx, cli, queryServiceName, queryContainer.Container.ID,
|
||||
[]string{"nslookup", dnsQuery},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
return dnsOutput
|
||||
}
|
||||
|
||||
// Helper function to verify DNS output doesn't contain errors
|
||||
assertNoDNSErrors := func(t *testing.T, dnsOutput string) {
|
||||
assert.NotContains(t, dnsOutput, "can't resolve", "DNS query should not contain resolution errors")
|
||||
assert.NotContains(t, dnsOutput, "Name or service not known",
|
||||
"DNS query should not contain unknown service errors")
|
||||
}
|
||||
|
||||
t.Run("service name resolves to all container IPs", func(t *testing.T) {
|
||||
dnsOutput := runNslookup(t, serviceName+".internal")
|
||||
t.Logf("DNS query output:\n%s", dnsOutput)
|
||||
|
||||
// Verify that all service container IPs are in the DNS response
|
||||
for _, ctr := range svc.Containers {
|
||||
containerIP := ctr.Container.UncloudNetworkIP().String()
|
||||
assert.Contains(t, dnsOutput, containerIP,
|
||||
"Service DNS should resolve to container IP %s", containerIP)
|
||||
}
|
||||
|
||||
assertNoDNSErrors(t, dnsOutput)
|
||||
})
|
||||
|
||||
t.Run("machine-specific service DNS lookups", func(t *testing.T) {
|
||||
// Test the new <machine-id>.m.<service-name>.internal DNS feature
|
||||
for _, targetContainer := range svc.Containers {
|
||||
targetMachineID := targetContainer.MachineID
|
||||
targetContainerIP := targetContainer.Container.UncloudNetworkIP().String()
|
||||
|
||||
// Construct the machine-specific DNS name
|
||||
machineSpecificDNS := targetMachineID + ".m." + serviceName + ".internal"
|
||||
|
||||
dnsOutput := runNslookup(t, machineSpecificDNS)
|
||||
t.Logf("Machine-specific DNS query output for %s:\n%s", machineSpecificDNS, dnsOutput)
|
||||
|
||||
// Verify that the specific container IP is returned
|
||||
assert.Contains(t, dnsOutput, targetContainerIP,
|
||||
"Machine-specific DNS %s should resolve to container IP %s",
|
||||
machineSpecificDNS, targetContainerIP)
|
||||
|
||||
// Verify that other container IPs are not returned (machine-specific should return only one IP)
|
||||
for _, ctr := range svc.Containers {
|
||||
if ctr.MachineID != targetMachineID {
|
||||
otherContainerIP := ctr.Container.UncloudNetworkIP().String()
|
||||
assert.NotContains(t, dnsOutput, otherContainerIP,
|
||||
"Machine-specific DNS %s should not resolve to other container IP %s",
|
||||
machineSpecificDNS, otherContainerIP)
|
||||
}
|
||||
}
|
||||
|
||||
assertNoDNSErrors(t, dnsOutput)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("service ID DNS lookup", func(t *testing.T) {
|
||||
// Test that service ID also resolves (existing functionality)
|
||||
dnsOutput := runNslookup(t, svc.ID+".internal")
|
||||
t.Logf("Service ID DNS query output:\n%s", dnsOutput)
|
||||
|
||||
// Verify that all service container IPs are in the DNS response
|
||||
for _, ctr := range svc.Containers {
|
||||
containerIP := ctr.Container.UncloudNetworkIP().String()
|
||||
assert.Contains(t, dnsOutput, containerIP,
|
||||
"Service ID DNS should resolve to container IP %s", containerIP)
|
||||
}
|
||||
|
||||
assertNoDNSErrors(t, dnsOutput)
|
||||
})
|
||||
|
||||
t.Run("nearest mode prioritizes local subnet IPs", func(t *testing.T) {
|
||||
// Find the service container on the same machine as the query container.
|
||||
var localIP string
|
||||
for _, ctr := range svc.Containers {
|
||||
if ctr.MachineID == queryContainer.MachineID {
|
||||
localIP = ctr.Container.UncloudNetworkIP().String()
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, localIP, "Should find local container IP on query machine %s", queryContainer.MachineID)
|
||||
|
||||
// We will extract the first IP address from the DNS output using a regex.
|
||||
// Pattern matches "Name: nearest.test-dns-service.internal" followed by "Address: X.X.X.X".
|
||||
re := regexp.MustCompile(`(?m)Name:\s+[\w\.\-]+\s+Address:\s+([\d\.]+)`)
|
||||
|
||||
// Test the "nearest" mode which should sort local subnet IPs first.
|
||||
// The default behavior randomizes the order, so run it a few times
|
||||
// to reduce the chance we're just getting lucky with the order.
|
||||
for range 5 {
|
||||
dnsOutput := runNslookup(t, "nearest."+serviceName+".internal")
|
||||
t.Logf("Nearest mode DNS query output:\n%s", dnsOutput)
|
||||
|
||||
matches := re.FindStringSubmatch(dnsOutput)
|
||||
require.Len(t, matches, 2, "Should find Name's Address in DNS output")
|
||||
firstIP := matches[1]
|
||||
assert.Equal(t, localIP, firstIP,
|
||||
"Nearest mode should return local subnet IP first (query machine: %s, local IP: %s, first DNS result: %s)",
|
||||
queryContainer.MachineID, localIP, firstIP)
|
||||
|
||||
assertNoDNSErrors(t, dnsOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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:
|
||||
+61
-75
@@ -12,10 +12,10 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMachineRename(t *testing.T) {
|
||||
func TestMachineOperations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
name := "ucind-test.machine-rename"
|
||||
name := "ucind-test.machine-ops"
|
||||
ctx := context.Background()
|
||||
c, _ := createTestCluster(t, name, ucind.CreateClusterOptions{Machines: 3}, true)
|
||||
|
||||
@@ -23,59 +23,61 @@ func TestMachineRename(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer cli.Close()
|
||||
|
||||
// RenameMachine subtests.
|
||||
|
||||
t.Run("rename machine by name", func(t *testing.T) {
|
||||
// Get initial machine state
|
||||
// Get initial machine state.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, machines, 3)
|
||||
|
||||
// Select the second machine to rename
|
||||
// Select the second machine to rename.
|
||||
originalMachine := machines[1]
|
||||
originalName := originalMachine.Machine.Name
|
||||
newName := "renamed-machine-1"
|
||||
|
||||
// Rename the machine
|
||||
// Rename the machine.
|
||||
updatedMachine, err := cli.RenameMachine(ctx, originalName, newName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, updatedMachine.Name)
|
||||
assert.Equal(t, originalMachine.Machine.Id, updatedMachine.Id)
|
||||
|
||||
// Verify the machine list reflects the change
|
||||
// Verify the machine list reflects the change.
|
||||
machines, err = cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, machines, 3)
|
||||
|
||||
// Find the renamed machine
|
||||
// Find the renamed machine.
|
||||
var found bool
|
||||
for _, m := range machines {
|
||||
if m.Machine.Id == originalMachine.Machine.Id {
|
||||
assert.Equal(t, newName, m.Machine.Name)
|
||||
found = true
|
||||
} else {
|
||||
// Ensure other machines are unaffected
|
||||
// Ensure other machines are unaffected.
|
||||
assert.NotEqual(t, newName, m.Machine.Name)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Renamed machine should be in the list")
|
||||
|
||||
// Verify we can inspect the machine by its new name
|
||||
// Verify we can inspect the machine by its new name.
|
||||
inspectedMachine, err := cli.InspectMachine(ctx, newName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, inspectedMachine.Machine.Name)
|
||||
assert.Equal(t, originalMachine.Machine.Id, inspectedMachine.Machine.Id)
|
||||
|
||||
// Verify the old name no longer works
|
||||
// Verify the old name no longer works.
|
||||
_, err = cli.InspectMachine(ctx, originalName)
|
||||
assert.ErrorIs(t, err, api.ErrNotFound)
|
||||
})
|
||||
|
||||
t.Run("rename machine by ID", func(t *testing.T) {
|
||||
// Get the third machine
|
||||
// Get the third machine.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, machines, 3)
|
||||
|
||||
// Find a machine that hasn't been renamed yet
|
||||
// Find a machine that hasn't been renamed yet.
|
||||
var targetMachine *pb.MachineMember
|
||||
for _, m := range machines {
|
||||
if m.Machine.Name != "renamed-machine-1" {
|
||||
@@ -89,67 +91,67 @@ func TestMachineRename(t *testing.T) {
|
||||
machineID := targetMachine.Machine.Id
|
||||
newName := "renamed-machine-2"
|
||||
|
||||
// Rename using ID instead of name
|
||||
// Rename using ID instead of name.
|
||||
updatedMachine, err := cli.RenameMachine(ctx, machineID, newName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, updatedMachine.Name)
|
||||
assert.Equal(t, machineID, updatedMachine.Id)
|
||||
|
||||
// Verify the rename was successful
|
||||
// Verify the rename was successful.
|
||||
inspectedMachine, err := cli.InspectMachine(ctx, newName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, inspectedMachine.Machine.Name)
|
||||
assert.Equal(t, machineID, inspectedMachine.Machine.Id)
|
||||
|
||||
// Verify the old name no longer works
|
||||
// Verify the old name no longer works.
|
||||
_, err = cli.InspectMachine(ctx, originalName)
|
||||
assert.ErrorIs(t, err, api.ErrNotFound)
|
||||
})
|
||||
|
||||
t.Run("rename non-existent machine", func(t *testing.T) {
|
||||
// Try to rename a machine that doesn't exist
|
||||
// Try to rename a machine that doesn't exist.
|
||||
_, err := cli.RenameMachine(ctx, "non-existent-machine", "new-name")
|
||||
assert.ErrorIs(t, err, api.ErrNotFound)
|
||||
|
||||
// Try with a non-existent ID
|
||||
// Try with a non-existent ID.
|
||||
_, err = cli.RenameMachine(ctx, "non-existent-id-12345", "new-name")
|
||||
assert.ErrorIs(t, err, api.ErrNotFound)
|
||||
})
|
||||
|
||||
t.Run("rename to existing name", func(t *testing.T) {
|
||||
// Get current machines
|
||||
// Get current machines.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, machines, 3)
|
||||
|
||||
// Try to rename machine 0 to the name of machine 1
|
||||
// Try to rename machine 0 to the name of machine 1.
|
||||
machine0Name := machines[0].Machine.Name
|
||||
machine1Name := machines[1].Machine.Name
|
||||
|
||||
// This should fail because the name is already taken
|
||||
// This should fail because the name is already taken.
|
||||
_, err = cli.RenameMachine(ctx, machine0Name, machine1Name)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("rename with empty name", func(t *testing.T) {
|
||||
// Get a machine to rename
|
||||
// Get a machine to rename.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, machines, 3)
|
||||
|
||||
machineName := machines[0].Machine.Name
|
||||
|
||||
// Try to rename with empty string
|
||||
// Try to rename with empty string.
|
||||
_, err = cli.RenameMachine(ctx, machineName, "")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("service continuity after rename", func(t *testing.T) {
|
||||
// Deploy a service on a specific machine
|
||||
// Deploy a service on a specific machine.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find a machine that hasn't been renamed to test with
|
||||
// Find a machine that hasn't been renamed to test with.
|
||||
var targetMachine *pb.MachineMember
|
||||
for _, m := range machines {
|
||||
if m.Machine.Name != "renamed-machine-1" && m.Machine.Name != "renamed-machine-2" {
|
||||
@@ -162,7 +164,7 @@ func TestMachineRename(t *testing.T) {
|
||||
originalMachineName := targetMachine.Machine.Name
|
||||
serviceName := "test-service-rename-continuity"
|
||||
|
||||
// Create a service on the specific machine
|
||||
// Create a service on the specific machine.
|
||||
spec := api.ServiceSpec{
|
||||
Name: serviceName,
|
||||
Mode: api.ServiceModeGlobal,
|
||||
@@ -184,51 +186,41 @@ func TestMachineRename(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Verify service is running on the machine
|
||||
// Verify service is running on the machine.
|
||||
svc, err := cli.InspectService(ctx, serviceName)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, svc.Containers, 1)
|
||||
assert.Equal(t, targetMachine.Machine.Id, svc.Containers[0].MachineID)
|
||||
|
||||
// Rename the machine
|
||||
// Rename the machine.
|
||||
newMachineName := "renamed-for-service-test"
|
||||
_, err = cli.RenameMachine(ctx, originalMachineName, newMachineName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify service is still running on the renamed machine
|
||||
// Verify service is still running on the renamed machine.
|
||||
svc, err = cli.InspectService(ctx, serviceName)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, svc.Containers, 1)
|
||||
assert.Equal(t, targetMachine.Machine.Id, svc.Containers[0].MachineID)
|
||||
|
||||
// The service spec's placement still references the old name,
|
||||
// but the service should continue to run on the same machine id
|
||||
// but the service should continue to run on the same machine id.
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateMachine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
name := "ucind-test.machine-update"
|
||||
ctx := context.Background()
|
||||
c, _ := createTestCluster(t, name, ucind.CreateClusterOptions{Machines: 3}, true)
|
||||
|
||||
cli, err := c.Machines[0].Connect(ctx)
|
||||
require.NoError(t, err)
|
||||
defer cli.Close()
|
||||
// UpdateMachine subtests.
|
||||
|
||||
t.Run("update machine name", func(t *testing.T) {
|
||||
// Get initial machine state
|
||||
// Get initial machine state.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, machines, 3)
|
||||
|
||||
// Select a machine to update
|
||||
// Select a machine to update.
|
||||
targetMachine := machines[1]
|
||||
originalName := targetMachine.Machine.Name
|
||||
newName := "updated-machine-name"
|
||||
|
||||
// Update the machine name using UpdateMachine directly
|
||||
// Update the machine name using UpdateMachine directly.
|
||||
req := &pb.UpdateMachineRequest{
|
||||
MachineId: targetMachine.Machine.Id,
|
||||
Name: &newName,
|
||||
@@ -238,22 +230,22 @@ func TestUpdateMachine(t *testing.T) {
|
||||
assert.Equal(t, newName, updatedMachine.Name)
|
||||
assert.Equal(t, targetMachine.Machine.Id, updatedMachine.Id)
|
||||
|
||||
// Verify the change persisted
|
||||
// Verify the change persisted.
|
||||
inspected, err := cli.InspectMachine(ctx, updatedMachine.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, inspected.Machine.Name)
|
||||
|
||||
// Verify old name no longer works
|
||||
// Verify old name no longer works.
|
||||
_, err = cli.InspectMachine(ctx, originalName)
|
||||
assert.ErrorIs(t, err, api.ErrNotFound)
|
||||
})
|
||||
|
||||
t.Run("update machine public IP", func(t *testing.T) {
|
||||
// Get a machine to update
|
||||
// Get a machine to update.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find a machine that hasn't been renamed
|
||||
// Find a machine that hasn't been renamed.
|
||||
var targetMachine *pb.MachineMember
|
||||
for _, m := range machines {
|
||||
if m.Machine.Name != "updated-machine-name" {
|
||||
@@ -263,12 +255,12 @@ func TestUpdateMachine(t *testing.T) {
|
||||
}
|
||||
require.NotNil(t, targetMachine)
|
||||
|
||||
// Create a new public IP (must be a valid public IP address)
|
||||
// Create a new public IP (must be a valid public IP address).
|
||||
newPublicIP := &pb.IP{
|
||||
Ip: []byte{8, 8, 8, 8},
|
||||
}
|
||||
|
||||
// Update the public IP
|
||||
// Update the public IP.
|
||||
req := &pb.UpdateMachineRequest{
|
||||
MachineId: targetMachine.Machine.Id,
|
||||
PublicIp: newPublicIP,
|
||||
@@ -277,31 +269,31 @@ func TestUpdateMachine(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newPublicIP.Ip, updatedMachine.PublicIp.Ip)
|
||||
|
||||
// Verify the change persisted
|
||||
// Verify the change persisted.
|
||||
inspected, err := cli.InspectMachine(ctx, targetMachine.Machine.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newPublicIP.Ip, inspected.Machine.PublicIp.Ip)
|
||||
})
|
||||
|
||||
t.Run("remove machine public IP", func(t *testing.T) {
|
||||
// Get machines
|
||||
// Get machines.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, len(machines) > 0, "Need at least one machine")
|
||||
|
||||
// First, set a public IP on a machine
|
||||
// First, set a public IP on a machine.
|
||||
targetMachine := machines[0]
|
||||
setIPReq := &pb.UpdateMachineRequest{
|
||||
MachineId: targetMachine.Machine.Id,
|
||||
PublicIp: &pb.IP{Ip: []byte{192, 0, 2, 1}}, // TEST-NET-1 address
|
||||
PublicIp: &pb.IP{Ip: []byte{192, 0, 2, 1}}, // TEST-NET-1 address.
|
||||
}
|
||||
updatedMachine, err := cli.UpdateMachine(ctx, setIPReq)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedMachine.PublicIp)
|
||||
|
||||
// Now test removing the public IP
|
||||
// Now test removing the public IP.
|
||||
|
||||
// Remove the public IP by setting it to empty
|
||||
// Remove the public IP by setting it to empty.
|
||||
emptyIP := &pb.IP{}
|
||||
req := &pb.UpdateMachineRequest{
|
||||
MachineId: updatedMachine.Id,
|
||||
@@ -311,23 +303,17 @@ func TestUpdateMachine(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, removedIPMachine.PublicIp)
|
||||
|
||||
// Verify the change persisted
|
||||
// Verify the change persisted.
|
||||
inspected, err := cli.InspectMachine(ctx, updatedMachine.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, inspected.Machine.PublicIp)
|
||||
})
|
||||
|
||||
t.Run("update machine endpoints", func(t *testing.T) {
|
||||
// Get a machine to update
|
||||
// Get a machine to update.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var targetMachine *pb.MachineMember
|
||||
for _, m := range machines {
|
||||
targetMachine = m
|
||||
break
|
||||
}
|
||||
require.NotNil(t, targetMachine)
|
||||
targetMachine := machines[0]
|
||||
|
||||
newEndpoints := []*pb.IPPort{
|
||||
{
|
||||
@@ -348,13 +334,13 @@ func TestUpdateMachine(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(newEndpoints), len(updatedMachine.Network.Endpoints))
|
||||
|
||||
// Verify endpoints were updated
|
||||
// Verify endpoints were updated.
|
||||
for i, endpoint := range updatedMachine.Network.Endpoints {
|
||||
assert.Equal(t, newEndpoints[i].Ip.Ip, endpoint.Ip.Ip)
|
||||
assert.Equal(t, newEndpoints[i].Port, endpoint.Port)
|
||||
}
|
||||
|
||||
// Verify other network fields remain unchanged
|
||||
// Verify other network fields remain unchanged.
|
||||
assert.Equal(t, targetMachine.Machine.Network.Subnet.Ip.Ip, updatedMachine.Network.Subnet.Ip.Ip)
|
||||
assert.Equal(t, targetMachine.Machine.Network.Subnet.Bits, updatedMachine.Network.Subnet.Bits)
|
||||
assert.Equal(t, targetMachine.Machine.Network.ManagementIp.Ip, updatedMachine.Network.ManagementIp.Ip)
|
||||
@@ -362,7 +348,7 @@ func TestUpdateMachine(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("update multiple fields simultaneously", func(t *testing.T) {
|
||||
// Get a machine to update
|
||||
// Get a machine to update.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -375,7 +361,7 @@ func TestUpdateMachine(t *testing.T) {
|
||||
}
|
||||
require.NotNil(t, targetMachine)
|
||||
|
||||
// Update both name and public IP
|
||||
// Update both name and public IP.
|
||||
newName := "multi-update-machine"
|
||||
newPublicIP := &pb.IP{
|
||||
Ip: []byte{1, 1, 1, 1},
|
||||
@@ -391,7 +377,7 @@ func TestUpdateMachine(t *testing.T) {
|
||||
assert.Equal(t, newName, updatedMachine.Name)
|
||||
assert.Equal(t, newPublicIP.Ip, updatedMachine.PublicIp.Ip)
|
||||
|
||||
// Verify both changes persisted
|
||||
// Verify both changes persisted.
|
||||
inspected, err := cli.InspectMachine(ctx, updatedMachine.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, inspected.Machine.Name)
|
||||
@@ -399,7 +385,7 @@ func TestUpdateMachine(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("update non-existent machine", func(t *testing.T) {
|
||||
// Try to update properties on a machine that doesn't exist
|
||||
// Try to update properties on a machine that doesn't exist.
|
||||
nonExistentName := "should-be-updated"
|
||||
req := &pb.UpdateMachineRequest{
|
||||
MachineId: "non-existent-machine-id",
|
||||
@@ -410,7 +396,7 @@ func TestUpdateMachine(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("update to duplicate name", func(t *testing.T) {
|
||||
// Get two machines
|
||||
// Get two machines.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, machines, 3)
|
||||
@@ -418,7 +404,7 @@ func TestUpdateMachine(t *testing.T) {
|
||||
machine1 := machines[0]
|
||||
machine2 := machines[1]
|
||||
|
||||
// Try to update machine2 with machine1's name
|
||||
// Try to update machine2 with machine1's name.
|
||||
req := &pb.UpdateMachineRequest{
|
||||
MachineId: machine2.Machine.Id,
|
||||
Name: &machine1.Machine.Name,
|
||||
@@ -428,20 +414,20 @@ func TestUpdateMachine(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("update with empty request", func(t *testing.T) {
|
||||
// Get a machine
|
||||
// Get a machine.
|
||||
machines, err := cli.ListMachines(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
targetMachine := machines[0]
|
||||
|
||||
// Update with no fields set (should be a no-op)
|
||||
// Update with no fields set (should be a no-op).
|
||||
req := &pb.UpdateMachineRequest{
|
||||
MachineId: targetMachine.Machine.Id,
|
||||
}
|
||||
updatedMachine, err := cli.UpdateMachine(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Machine should remain unchanged
|
||||
// Machine should remain unchanged.
|
||||
assert.Equal(t, targetMachine.Machine.Name, updatedMachine.Name)
|
||||
if targetMachine.Machine.PublicIp != nil && updatedMachine.PublicIp != nil {
|
||||
assert.Equal(t, targetMachine.Machine.PublicIp.Ip, updatedMachine.PublicIp.Ip)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
+378
-4
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -56,11 +57,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 +79,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 +96,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 +120,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 +146,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 +177,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 +527,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 +536,9 @@ myapp.example.com {
|
||||
Image: "portainer/pause:latest",
|
||||
},
|
||||
Replicas: 2,
|
||||
UpdateConfig: api.UpdateConfig{
|
||||
MonitorPeriod: &monitorPeriod,
|
||||
},
|
||||
}
|
||||
deployment := cli.NewDeployment(spec, nil)
|
||||
|
||||
@@ -519,7 +551,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 +567,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 +582,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 +601,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 +643,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 +1263,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 +1317,169 @@ 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 := 3 * 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)
|
||||
}
|
||||
})
|
||||
|
||||
// 1 second is sometimes not enough for Docker to detect the crash and start restarting the container.
|
||||
monitorPeriod := 3 * 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 +1531,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 +1541,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{
|
||||
@@ -1798,4 +2023,153 @@ func TestServiceLifecycle(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "machines not found")
|
||||
})
|
||||
|
||||
t.Run("internal DNS", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Deploy a global service so each machine gets one container.
|
||||
serviceName := "test-dns-service"
|
||||
t.Cleanup(func() {
|
||||
err := cli.RemoveService(ctx, serviceName)
|
||||
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
spec := api.ServiceSpec{
|
||||
Name: serviceName,
|
||||
Mode: api.ServiceModeGlobal,
|
||||
Container: api.ContainerSpec{
|
||||
Image: "portainer/pause:latest",
|
||||
},
|
||||
}
|
||||
_, err := cli.RunService(ctx, spec)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := cli.InspectService(ctx, serviceName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Deploy a test service to run DNS queries from.
|
||||
queryServiceName := "test-dns-query-service"
|
||||
t.Cleanup(func() {
|
||||
err := cli.RemoveService(ctx, queryServiceName)
|
||||
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
querySvcSpec := api.ServiceSpec{
|
||||
Name: queryServiceName,
|
||||
Mode: api.ServiceModeReplicated,
|
||||
Replicas: 1,
|
||||
Placement: api.Placement{
|
||||
Machines: []string{c.Machines[0].Name},
|
||||
},
|
||||
Container: api.ContainerSpec{
|
||||
Image: "alpine:3.20",
|
||||
Command: []string{"sleep", "infinity"},
|
||||
},
|
||||
}
|
||||
_, err = cli.RunService(ctx, querySvcSpec)
|
||||
require.NoError(t, err)
|
||||
|
||||
querySvc, err := cli.InspectService(ctx, queryServiceName)
|
||||
require.NoError(t, err)
|
||||
queryContainer := querySvc.Containers[0]
|
||||
|
||||
runNslookup := func(t *testing.T, dnsQuery string) string {
|
||||
dnsOutput, err := execInContainerAndReadOutput(
|
||||
t, ctx, cli, queryServiceName, queryContainer.Container.ID,
|
||||
[]string{"nslookup", dnsQuery},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
return dnsOutput
|
||||
}
|
||||
|
||||
assertNoDNSErrors := func(t *testing.T, dnsOutput string) {
|
||||
assert.NotContains(t, dnsOutput, "server can't find", "DNS query should not contain NXDOMAIN/SERVFAIL errors")
|
||||
}
|
||||
|
||||
t.Run("service name resolves to all container IPs", func(t *testing.T) {
|
||||
dnsOutput := runNslookup(t, serviceName+".internal")
|
||||
t.Logf("DNS query output:\n%s", dnsOutput)
|
||||
|
||||
for _, ctr := range svc.Containers {
|
||||
containerIP := ctr.Container.UncloudNetworkIP().String()
|
||||
assert.Contains(t, dnsOutput, containerIP,
|
||||
"Service DNS should resolve to container IP %s", containerIP)
|
||||
}
|
||||
|
||||
assertNoDNSErrors(t, dnsOutput)
|
||||
})
|
||||
|
||||
t.Run("machine-specific service DNS lookups", func(t *testing.T) {
|
||||
for _, targetContainer := range svc.Containers {
|
||||
targetMachineID := targetContainer.MachineID
|
||||
targetContainerIP := targetContainer.Container.UncloudNetworkIP().String()
|
||||
|
||||
machineSpecificDNS := targetMachineID + ".m." + serviceName + ".internal"
|
||||
|
||||
dnsOutput := runNslookup(t, machineSpecificDNS)
|
||||
t.Logf("Machine-specific DNS query output for %s:\n%s", machineSpecificDNS, dnsOutput)
|
||||
|
||||
assert.Contains(t, dnsOutput, targetContainerIP,
|
||||
"Machine-specific DNS %s should resolve to container IP %s",
|
||||
machineSpecificDNS, targetContainerIP)
|
||||
|
||||
// Machine-specific lookup should return only the container on that machine.
|
||||
for _, ctr := range svc.Containers {
|
||||
if ctr.MachineID != targetMachineID {
|
||||
otherContainerIP := ctr.Container.UncloudNetworkIP().String()
|
||||
assert.NotContains(t, dnsOutput, otherContainerIP,
|
||||
"Machine-specific DNS %s should not resolve to other container IP %s",
|
||||
machineSpecificDNS, otherContainerIP)
|
||||
}
|
||||
}
|
||||
|
||||
assertNoDNSErrors(t, dnsOutput)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("service ID DNS lookup", func(t *testing.T) {
|
||||
dnsOutput := runNslookup(t, svc.ID+".internal")
|
||||
t.Logf("Service ID DNS query output:\n%s", dnsOutput)
|
||||
|
||||
for _, ctr := range svc.Containers {
|
||||
containerIP := ctr.Container.UncloudNetworkIP().String()
|
||||
assert.Contains(t, dnsOutput, containerIP,
|
||||
"Service ID DNS should resolve to container IP %s", containerIP)
|
||||
}
|
||||
|
||||
assertNoDNSErrors(t, dnsOutput)
|
||||
})
|
||||
|
||||
t.Run("nearest mode prioritizes local subnet IPs", func(t *testing.T) {
|
||||
var localIP string
|
||||
for _, ctr := range svc.Containers {
|
||||
if ctr.MachineID == queryContainer.MachineID {
|
||||
localIP = ctr.Container.UncloudNetworkIP().String()
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, localIP, "Should find local container IP on query machine %s", queryContainer.MachineID)
|
||||
|
||||
// Extracts the first IP from nslookup output after the "Name:" line.
|
||||
re := regexp.MustCompile(`(?m)Name:\s+[\w\.\-]+\s+Address:\s+([\d\.]+)`)
|
||||
|
||||
// The default mode randomizes IP order, so run multiple times to reduce the chance of passing by coincidence.
|
||||
for range 5 {
|
||||
dnsOutput := runNslookup(t, "nearest."+serviceName+".internal")
|
||||
t.Logf("Nearest mode DNS query output:\n%s", dnsOutput)
|
||||
|
||||
matches := re.FindStringSubmatch(dnsOutput)
|
||||
require.Len(t, matches, 2, "Should find Name's Address in DNS output")
|
||||
firstIP := matches[1]
|
||||
assert.Equal(t, localIP, firstIP,
|
||||
"Nearest mode should return local subnet IP first (query machine: %s, local IP: %s, first DNS result: %s)",
|
||||
queryContainer.MachineID, localIP, firstIP)
|
||||
|
||||
assertNoDNSErrors(t, dnsOutput)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
deploy:
|
||||
update_config:
|
||||
# Specified as duration: 500ms, 20s, 1m30s, 0s (skip)
|
||||
monitor: 10s
|
||||
```
|
||||
|
||||
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. A safer alternative is to configure a [health check](#health-checks) instead.
|
||||
|
||||
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 that service.
|
||||
|
||||
### Health checks
|
||||
|
||||
If your service 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 a 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,69 @@
|
||||
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 | |
|
||||
| `stop_grace_period` | ✅ Supported | Time to wait after SIGTERM before SIGKILL |
|
||||
| `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]
|
||||
```
|
||||
|
||||
@@ -25,6 +25,5 @@ Manage machines in the cluster.
|
||||
* [uc machine ls](uc_machine_ls.md) - List machines in a cluster.
|
||||
* [uc machine rename](uc_machine_rename.md) - Rename a machine in the cluster.
|
||||
* [uc machine rm](uc_machine_rm.md) - Remove a machine from a cluster and reset it.
|
||||
* [uc machine token](uc_machine_token.md) - Print the local machine's token for adding it to a cluster.
|
||||
* [uc machine update](uc_machine_update.md) - Update machine configuration in the cluster.
|
||||
|
||||
|
||||
@@ -17,15 +17,19 @@ uc machine add [USER@]HOST[:PORT] [flags]
|
||||
## Options
|
||||
|
||||
```
|
||||
-h, --help help for add
|
||||
-n, --name string Assign a name to the machine.
|
||||
--no-caddy Don't deploy Caddy reverse proxy service to the machine.
|
||||
--no-install Skip installation of Docker, Uncloud daemon, and dependencies on the machine. Assumes they're already installed and running.
|
||||
--public-ip string Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, blank '' or 'none' to disable ingress on this machine, or specify an IP address. (default "auto")
|
||||
-i, --ssh-key string Path to SSH private key for remote login (if not already added to SSH agent). (default "~/.ssh/id_ed25519")
|
||||
--version string Version of the Uncloud daemon to install on the machine. (default "latest")
|
||||
-y, --yes Auto-confirm prompts (e.g., resetting an already initialised machine).
|
||||
Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]
|
||||
-h, --help help for add
|
||||
-n, --name string Assign a name to the machine.
|
||||
--no-caddy Don't deploy Caddy reverse proxy service to the machine.
|
||||
--no-install Skip installation of Docker, Uncloud daemon, and dependencies on the machine. Assumes they're already installed and running.
|
||||
--public-ip string Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, blank '' or 'none' to disable ingress on this machine, or specify an IP address. (default "auto")
|
||||
-i, --ssh-key string Path to SSH private key for remote login (if not already added to SSH agent). (default "~/.ssh/id_ed25519")
|
||||
--version string Version of the Uncloud daemon to install on the machine. (default "latest")
|
||||
--wg-endpoint strings WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port 51820 is used if omitted.
|
||||
Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.
|
||||
Multiple endpoints can be specified by repeating the flag or using a comma-separated list.
|
||||
If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.
|
||||
-y, --yes Auto-confirm prompts (e.g., resetting an already initialised machine).
|
||||
Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
||||
@@ -46,6 +46,10 @@ uc machine init [schema://]USER@HOST[:PORT] [flags]
|
||||
--public-ip string Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, blank '' or 'none' to disable ingress on this machine, or specify an IP address. (default "auto")
|
||||
-i, --ssh-key string Path to SSH private key for remote login (if not already added to SSH agent). (default "~/.ssh/id_ed25519")
|
||||
--version string Version of the Uncloud daemon to install on the machine. (default "latest")
|
||||
--wg-endpoint strings WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port 51820 is used if omitted.
|
||||
Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.
|
||||
Multiple endpoints can be specified by repeating the flag or using a comma-separated list.
|
||||
If not specified, the machine's routable IPs and public IP are auto-detected and used as endpoints.
|
||||
-y, --yes Auto-confirm prompts (e.g., resetting an already initialised machine).
|
||||
Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]
|
||||
```
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# uc machine token
|
||||
|
||||
Print the local machine's token for adding it to a cluster.
|
||||
|
||||
```
|
||||
uc machine token [flags]
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```
|
||||
-d, --data-dir string Directory for storing persistent machine state. (default "/var/lib/uncloud")
|
||||
-h, --help help for token
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
||||
```
|
||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
* [uc machine](uc_machine.md) - Manage machines in the cluster.
|
||||
|
||||
@@ -6,22 +6,41 @@ Update machine configuration in the cluster.
|
||||
|
||||
Update machine configuration in the cluster.
|
||||
|
||||
This command allows setting various machine properties including:
|
||||
- Machine name (--name)
|
||||
- Public IP address (--public-ip)
|
||||
|
||||
At least one flag must be specified to perform an update operation.
|
||||
Change the name, public IP address, or WireGuard endpoints of an existing machine.
|
||||
At least one flag must be specified to perform an update.
|
||||
|
||||
```
|
||||
uc machine update [flags]
|
||||
uc machine update MACHINE [flags]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
# Rename a machine.
|
||||
uc machine update machine1 --name web-server
|
||||
|
||||
# Set the public IP address of a machine.
|
||||
uc machine update machine1 --public-ip 203.0.113.10
|
||||
|
||||
# Remove the public IP address from a machine.
|
||||
uc machine update machine1 --public-ip none
|
||||
|
||||
# Update WireGuard endpoints for a machine.
|
||||
uc machine update machine1 --wg-endpoint 203.0.113.10 --wg-endpoint 192.168.1.5
|
||||
|
||||
# Update multiple properties at once.
|
||||
uc machine update machine1 --name web-server --public-ip 203.0.113.10
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```
|
||||
-h, --help help for update
|
||||
--name string New name for the machine
|
||||
--public-ip string Public IP address of the machine for ingress configuration. Use 'none' or '' to remove the public IP.
|
||||
-h, --help help for update
|
||||
--name string New name for the machine
|
||||
--public-ip string Public IP address of the machine for ingress configuration. Use 'none' or '' to remove the public IP.
|
||||
--wg-endpoint strings WireGuard endpoint address in format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port 51820 is used if omitted.
|
||||
Other machines in the cluster will use this endpoint to establish a WireGuard connection to this machine.
|
||||
Multiple endpoints can be specified by repeating the flag or using a comma-separated list.
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
||||
+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