mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07e380b95f | ||
|
|
4e060914ec | ||
|
|
f317dde168 | ||
|
|
f2f6fee89a | ||
|
|
b3b33a82ba | ||
|
|
cd149e4506 | ||
|
|
eff1bc88d8 | ||
|
|
ab644f6a47 | ||
|
|
883f184e37 | ||
|
|
897f30fd36 | ||
|
|
943fea0515 | ||
|
|
e96cce88c1 | ||
|
|
1930bb5766 | ||
|
|
a844cc6f67 | ||
|
|
043b85ab70 | ||
|
|
fe813302b7 | ||
|
|
3e30a59d4c | ||
|
|
0da9df17a4 | ||
|
|
0e820f9c52 | ||
|
|
772b31b57f | ||
|
|
1c8b77054a | ||
|
|
cef047221c | ||
|
|
4b34c42b76 | ||
|
|
7ec8879af3 | ||
|
|
cb77d42107 | ||
|
|
d45b5a488e | ||
|
|
f4581eac11 | ||
|
|
0651b9d0ac | ||
|
|
62976f3338 | ||
|
|
d38312fec6 | ||
|
|
636307d3ea | ||
|
|
e22cdf0b18 | ||
|
|
9aee75ba28 | ||
|
|
37c937a570 | ||
|
|
a1bde30b57 | ||
|
|
06c2bc55f2 | ||
|
|
0a9a1db815 |
@@ -197,6 +197,7 @@ uc context use <name> # Switch context
|
||||
- Integration tests in `test/e2e/`
|
||||
- Test fixtures in `test/fixtures/`
|
||||
- Use table driven tests whenever possible
|
||||
- Use the `testify` library for assertions (e.g., `require.Equal`, `assert.Nil`)
|
||||
|
||||
### Dependencies
|
||||
|
||||
|
||||
@@ -88,12 +88,12 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||
if len(currentImages) > 1 {
|
||||
formattedImages := make([]string, len(currentImages))
|
||||
for i, img := range currentImages {
|
||||
formattedImages[i] = tui.FormatImage(img, lipgloss.NewStyle())
|
||||
formattedImages[i] = tui.FormatImage(img, tui.NoStyle)
|
||||
}
|
||||
fmt.Println(tui.Faint.Render("current images (multiple versions detected): ") +
|
||||
strings.Join(formattedImages, tui.Faint.Render(", ")))
|
||||
} else {
|
||||
fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], lipgloss.NewStyle()))
|
||||
fmt.Println(tui.Faint.Render("current image: ") + tui.FormatImage(currentImages[0], tui.NoStyle))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@ package context
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -38,8 +37,8 @@ func list(uncli *cli.CLI) error {
|
||||
contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
|
||||
currentContext := uncli.Config.CurrentContext
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
fmt.Fprintln(tw, "NAME\tCURRENT\tCONNECTIONS")
|
||||
t := tui.NewTable()
|
||||
t.Headers("NAME", "CURRENT", "CONNECTIONS")
|
||||
|
||||
for _, name := range contextNames {
|
||||
current := ""
|
||||
@@ -47,8 +46,9 @@ func list(uncli *cli.CLI) error {
|
||||
current = "✓"
|
||||
}
|
||||
connCount := len(uncli.Config.Contexts[name].Connections)
|
||||
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
||||
t.Row(name, current, fmt.Sprintf("%d", connCount))
|
||||
}
|
||||
|
||||
return tw.Flush()
|
||||
fmt.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ import (
|
||||
|
||||
const docsDir = "website/docs/9-cli-reference"
|
||||
|
||||
type docOptions struct {
|
||||
manual bool
|
||||
}
|
||||
|
||||
type cmdWrapper struct {
|
||||
cmd *cobra.Command
|
||||
}
|
||||
@@ -20,6 +24,7 @@ type cmdWrapper struct {
|
||||
// NewDocsCommand creates a new hidden command to generate CLI reference docs.
|
||||
func NewDocsCommand() *cobra.Command {
|
||||
wrapper := &cmdWrapper{}
|
||||
opts := docOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "docs",
|
||||
Short: "Generate Uncloud CLI reference docs",
|
||||
@@ -29,6 +34,28 @@ func NewDocsCommand() *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
ValidArgsFunction: cobra.NoFileCompletions,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if opts.manual {
|
||||
header := &doc.GenManHeader{
|
||||
Title: "Uncloud",
|
||||
Section: "1",
|
||||
Source: "Uncloud https://uncloud.run",
|
||||
}
|
||||
if err := doc.GenManTree(cmd.Root(), header, "."); err != nil {
|
||||
return fmt.Errorf("generate CLI manual pages: %w", err)
|
||||
}
|
||||
completionFiles, err := filepath.Glob("uc-completion*.1")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list generated manual pages: %w", err)
|
||||
}
|
||||
for _, f := range completionFiles {
|
||||
if err = os.Remove(f); err != nil {
|
||||
return fmt.Errorf("remove '%s': %w", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove existing markdown files.
|
||||
mdFiles, err := filepath.Glob(filepath.Join(docsDir, "*.md"))
|
||||
if err != nil {
|
||||
@@ -72,6 +99,8 @@ func NewDocsCommand() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.manual, "manual", false,
|
||||
"Generate Uncloud manual pages in the current directory.")
|
||||
|
||||
wrapper.cmd = cmd
|
||||
return cmd
|
||||
|
||||
+3
-18
@@ -10,13 +10,13 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/go-units"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -260,22 +260,7 @@ func formatImageTable(rows []imageRow) string {
|
||||
columns[5].hide = true
|
||||
}
|
||||
|
||||
t := table.New().
|
||||
// Remove the default border.
|
||||
Border(lipgloss.Border{}).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderLeft(false).
|
||||
BorderRight(false).
|
||||
BorderHeader(false).
|
||||
BorderColumn(false).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
if row == table.HeaderRow {
|
||||
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
|
||||
}
|
||||
// Regular style for data rows with padding.
|
||||
return lipgloss.NewStyle().PaddingRight(3)
|
||||
})
|
||||
t := tui.NewTable()
|
||||
|
||||
var headers []string
|
||||
for _, col := range columns {
|
||||
@@ -288,7 +273,7 @@ func formatImageTable(rows []imageRow) string {
|
||||
for _, row := range rows {
|
||||
values := []string{
|
||||
row.id,
|
||||
row.name,
|
||||
tui.FormatImage(row.name, tui.NoStyle),
|
||||
row.platforms,
|
||||
row.createdHuman,
|
||||
row.size,
|
||||
|
||||
@@ -40,17 +40,18 @@ func NewAddCommand() *cobra.Command {
|
||||
Long: `Add a new machine to an existing Uncloud cluster.
|
||||
|
||||
Connection methods:
|
||||
ssh://user@host - Use built-in SSH library (default, no prefix required)
|
||||
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
|
||||
[ssh://]user@host - Use system 'ssh' command with full SSH config support (default, no prefix required)
|
||||
ssh+go://user@host - Use Go's built-in SSH library`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
// Determine if SSH CLI needs to be used and strip scheme
|
||||
// Determine connection mode and strip scheme.
|
||||
destination := args[0]
|
||||
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
|
||||
useSSHGo := strings.HasPrefix(destination, "ssh+go://")
|
||||
destination = strings.TrimPrefix(destination, "ssh+go://")
|
||||
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
||||
destination = strings.TrimPrefix(destination, "ssh://")
|
||||
|
||||
@@ -59,11 +60,11 @@ Connection methods:
|
||||
return fmt.Errorf("parse remote machine: %w", err)
|
||||
}
|
||||
remoteMachine := &cli.RemoteMachine{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
UseSSHCLI: useSSHCLI,
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
UseSSHGo: useSSHGo,
|
||||
}
|
||||
|
||||
return add(cmd.Context(), uncli, remoteMachine, opts)
|
||||
|
||||
@@ -45,8 +45,8 @@ func NewInitCommand() *cobra.Command {
|
||||
This command creates a new context in your Uncloud config to manage the cluster.
|
||||
|
||||
Connection methods:
|
||||
ssh://user@host - Use built-in SSH library (default, no prefix required)
|
||||
ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`,
|
||||
[ssh://]user@host - Use system 'ssh' command with full SSH config support (default, no prefix required)
|
||||
ssh+go://user@host - Use Go's built-in SSH library`,
|
||||
Example: ` # Initialise a new cluster with default settings.
|
||||
uc machine init root@<your-server-ip>
|
||||
|
||||
@@ -68,9 +68,10 @@ Connection methods:
|
||||
|
||||
var remoteMachine *cli.RemoteMachine
|
||||
if len(args) > 0 {
|
||||
// Determine if SSH CLI is requested and strip scheme
|
||||
// Determine connection mode and strip scheme.
|
||||
destination := args[0]
|
||||
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
|
||||
useSSHGo := strings.HasPrefix(destination, "ssh+go://")
|
||||
destination = strings.TrimPrefix(destination, "ssh+go://")
|
||||
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
||||
destination = strings.TrimPrefix(destination, "ssh://")
|
||||
|
||||
@@ -79,11 +80,11 @@ Connection methods:
|
||||
return fmt.Errorf("parse remote machine: %w", err)
|
||||
}
|
||||
remoteMachine = &cli.RemoteMachine{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
UseSSHCLI: useSSHCLI,
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
UseSSHGo: useSSHGo,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-15
@@ -4,11 +4,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -39,12 +38,9 @@ func list(ctx context.Context, uncli *cli.CLI) error {
|
||||
}
|
||||
|
||||
// Print the list of machines in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
// Print header.
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS\tMACHINE ID"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
// Print rows.
|
||||
t := tui.NewTable()
|
||||
t.Headers("NAME", "STATE", "ADDRESS", "PUBLIC IP", "WIREGUARD ENDPOINTS", "MACHINE ID")
|
||||
|
||||
for _, member := range machines {
|
||||
m := member.Machine
|
||||
subnet, _ := m.Network.Subnet.ToPrefix()
|
||||
@@ -62,14 +58,18 @@ func list(ctx context.Context, uncli *cli.CLI) error {
|
||||
endpoints[i] = addrPort.String()
|
||||
}
|
||||
|
||||
if _, err = fmt.Fprintf(
|
||||
tw, "%s\t%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||
strings.Join(endpoints, ", "), member.Machine.Id,
|
||||
); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
t.Row(
|
||||
m.Name,
|
||||
capitalise(member.State.String()),
|
||||
subnet.String(),
|
||||
publicIP,
|
||||
strings.Join(endpoints, tui.Faint.Render(", ")),
|
||||
member.Machine.Id,
|
||||
)
|
||||
}
|
||||
return tw.Flush()
|
||||
|
||||
fmt.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// capitalise returns a string where the first character is upper case, and the rest is lower case.
|
||||
|
||||
@@ -206,7 +206,7 @@ func formatContainerTree(containers []api.ServiceContainer) string {
|
||||
// Add containers as children.
|
||||
for _, ctr := range ctrs {
|
||||
state, _ := ctr.HumanState()
|
||||
info := fmt.Sprintf("%s • %s • %s", ctr.Name, ctr.Config.Image, state)
|
||||
info := fmt.Sprintf("%s • %s • %s", ctr.Name, tui.FormatImage(ctr.Config.Image, tui.NoStyle), state)
|
||||
t.Child(info)
|
||||
}
|
||||
|
||||
|
||||
+12
-5
@@ -47,23 +47,30 @@ func main() {
|
||||
var conn *config.MachineConnection
|
||||
if opts.connect != "" {
|
||||
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||
addrPort, err := netip.ParseAddrPort(opts.connect[len("tcp://"):])
|
||||
addrPort, err := netip.ParseAddrPort(strings.TrimPrefix(opts.connect, "tcp://"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse TCP address: %w", err)
|
||||
}
|
||||
conn = &config.MachineConnection{
|
||||
TCP: &addrPort,
|
||||
}
|
||||
} else if strings.HasPrefix(opts.connect, "ssh+cli://") {
|
||||
dest := opts.connect[len("ssh+cli://"):]
|
||||
} else if strings.HasPrefix(opts.connect, "ssh+go://") {
|
||||
dest := strings.TrimPrefix(opts.connect, "ssh+go://")
|
||||
conn = &config.MachineConnection{
|
||||
SSHCLI: config.SSHDestination(dest),
|
||||
SSHGo: config.SSHDestination(dest),
|
||||
}
|
||||
} else if strings.HasPrefix(opts.connect, "ssh+cli://") {
|
||||
// Backward-compatible alias for ssh://.
|
||||
dest := strings.TrimPrefix(opts.connect, "ssh+cli://")
|
||||
conn = &config.MachineConnection{
|
||||
SSH: config.SSHDestination(dest),
|
||||
}
|
||||
} else if strings.HasPrefix(opts.connect, "unix://") {
|
||||
conn = &config.MachineConnection{
|
||||
Unix: opts.connect[len("unix://"):],
|
||||
}
|
||||
} else {
|
||||
// Default: system ssh CLI command (no prefix or ssh:// prefix).
|
||||
dest := strings.TrimPrefix(opts.connect, "ssh://")
|
||||
conn = &config.MachineConnection{
|
||||
SSH: config.SSHDestination(dest),
|
||||
@@ -83,7 +90,7 @@ func main() {
|
||||
|
||||
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
||||
"Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock")
|
||||
"Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock")
|
||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||
|
||||
+47
-34
@@ -8,14 +8,13 @@ import (
|
||||
|
||||
"charm.land/huh/v2/spinner"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -67,12 +66,13 @@ type containerInfo struct {
|
||||
serviceName string
|
||||
machineName string
|
||||
id string
|
||||
name string
|
||||
image string
|
||||
status string
|
||||
highlight containerHighlight
|
||||
created time.Time
|
||||
ip string
|
||||
// Hook type (e.g., "pre-deploy"), empty for regular containers.
|
||||
hook string
|
||||
}
|
||||
|
||||
func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
|
||||
@@ -132,24 +132,22 @@ func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
|
||||
}
|
||||
|
||||
func printContainers(containers []containerInfo) error {
|
||||
t := table.New().
|
||||
// Remove the default border.
|
||||
Border(lipgloss.Border{}).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderLeft(false).
|
||||
BorderRight(false).
|
||||
BorderHeader(false).
|
||||
BorderColumn(false).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
if row == table.HeaderRow {
|
||||
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
|
||||
}
|
||||
// Regular style for data rows with padding.
|
||||
return lipgloss.NewStyle().PaddingRight(3)
|
||||
})
|
||||
t := tui.NewTable()
|
||||
|
||||
t.Headers("SERVICE", "CONTAINER ID", "CONTAINER NAME", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
|
||||
// Show HOOK column only when hook containers are present.
|
||||
hasHooks := false
|
||||
for _, ctr := range containers {
|
||||
if ctr.hook != "" {
|
||||
hasHooks = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasHooks {
|
||||
t.Headers("SERVICE", "CONTAINER ID", "IMAGE", "CREATED", "STATUS", "HOOK", "IP ADDRESS", "MACHINE")
|
||||
} else {
|
||||
t.Headers("SERVICE", "CONTAINER ID", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
|
||||
}
|
||||
|
||||
for _, ctr := range containers {
|
||||
id := ctr.id
|
||||
@@ -171,16 +169,28 @@ func printContainers(containers []containerInfo) error {
|
||||
statusStyle = lipgloss.NewStyle() // Default
|
||||
}
|
||||
|
||||
t.Row(
|
||||
ctr.serviceName,
|
||||
id,
|
||||
ctr.name,
|
||||
ctr.image,
|
||||
created,
|
||||
statusStyle.Render(ctr.status),
|
||||
ctr.ip,
|
||||
ctr.machineName,
|
||||
)
|
||||
if hasHooks {
|
||||
t.Row(
|
||||
ctr.serviceName,
|
||||
id,
|
||||
tui.FormatImage(ctr.image, tui.NoStyle),
|
||||
created,
|
||||
statusStyle.Render(ctr.status),
|
||||
ctr.hook,
|
||||
ctr.ip,
|
||||
ctr.machineName,
|
||||
)
|
||||
} else {
|
||||
t.Row(
|
||||
ctr.serviceName,
|
||||
id,
|
||||
tui.FormatImage(ctr.image, tui.NoStyle),
|
||||
created,
|
||||
statusStyle.Render(ctr.status),
|
||||
ctr.ip,
|
||||
ctr.machineName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(t)
|
||||
@@ -237,7 +247,7 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ctr := range msc.Containers {
|
||||
for _, ctr := range append(msc.Containers, msc.HookContainers...) {
|
||||
if ctr.Container.State == nil || ctr.Container.Config == nil {
|
||||
continue
|
||||
}
|
||||
@@ -259,6 +269,9 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
|
||||
highlight = highlightSuccess
|
||||
} else if ctr.Container.State.Status == "running" {
|
||||
highlight = highlightNormal
|
||||
} else if ctr.IsHook() && ctr.Container.State.Status == "exited" && ctr.Container.State.ExitCode == 0 {
|
||||
// Hook containers (e.g., pre-deploy) are expected to exit successfully.
|
||||
highlight = highlightNormal
|
||||
} else { // Other non-critical but noteworthy states
|
||||
highlight = highlightWarning
|
||||
}
|
||||
@@ -276,12 +289,12 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
|
||||
serviceName: ctr.ServiceName(),
|
||||
machineName: machineName,
|
||||
id: ctr.Container.ID,
|
||||
name: ctr.Container.Name,
|
||||
image: ctr.Container.Config.Image,
|
||||
status: status,
|
||||
highlight: highlight,
|
||||
created: created,
|
||||
ip: ipStr,
|
||||
hook: ctr.Config.Labels[api.LabelHook],
|
||||
}
|
||||
containers = append(containers, info)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ func TestCollectContainers_NilMetadata(t *testing.T) {
|
||||
if len(containers) > 0 {
|
||||
c := containers[0]
|
||||
assert.Equal(t, "container1", c.id)
|
||||
assert.Equal(t, "test-container", c.name)
|
||||
assert.Equal(t, "machine-1", c.machineName, "Should fall back to the single machine name when metadata is nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,7 @@ If the service has multiple replicas and no container ID is specified, the comma
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
serviceName := args[0]
|
||||
command := args[1:]
|
||||
serviceName, command := normalizeExecArgs(args)
|
||||
if len(command) == 0 {
|
||||
command = DEFAULT_COMMAND
|
||||
}
|
||||
@@ -82,6 +81,15 @@ If the service has multiple replicas and no container ID is specified, the comma
|
||||
return execCmd
|
||||
}
|
||||
|
||||
func normalizeExecArgs(args []string) (serviceName string, command []string) {
|
||||
serviceName = args[0]
|
||||
command = args[1:]
|
||||
if len(command) > 0 && command[0] == "--" {
|
||||
command = command[1:]
|
||||
}
|
||||
return serviceName, command
|
||||
}
|
||||
|
||||
func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error {
|
||||
// Disable TTY allocation if not connected to a terminal
|
||||
if !tui.IsStdoutTerminal() {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalizeExecArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantServiceName string
|
||||
wantCommand []string
|
||||
}{
|
||||
{
|
||||
name: "service only",
|
||||
args: []string{"test-service"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{},
|
||||
},
|
||||
{
|
||||
name: "service with command",
|
||||
args: []string{"test-service", "echo", "hello"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{"echo", "hello"},
|
||||
},
|
||||
{
|
||||
name: "service with separator and command",
|
||||
args: []string{"test-service", "--", "echo", "hello"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{"echo", "hello"},
|
||||
},
|
||||
{
|
||||
name: "service with separator only",
|
||||
args: []string{"test-service", "--"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{},
|
||||
},
|
||||
{
|
||||
name: "separator preserves command flag",
|
||||
args: []string{"test-service", "--", "--help"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{"--help"},
|
||||
},
|
||||
{
|
||||
name: "only first separator is removed",
|
||||
args: []string{"test-service", "--", "cmd", "--", "arg"},
|
||||
wantServiceName: "test-service",
|
||||
wantCommand: []string{"cmd", "--", "arg"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotServiceName, gotCommand := normalizeExecArgs(tt.args)
|
||||
assert.Equal(t, tt.wantServiceName, gotServiceName)
|
||||
assert.Equal(t, tt.wantCommand, gotCommand)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,13 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -61,25 +60,33 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||
fmt.Printf("Mode: %s\n", svc.Mode)
|
||||
fmt.Println()
|
||||
|
||||
// Combine regular and hook containers.
|
||||
allContainers := append(svc.Containers, svc.HookContainers...)
|
||||
|
||||
// Parse created times for sorting and display.
|
||||
createdTimes := make(map[string]time.Time, len(svc.Containers))
|
||||
for _, ctr := range svc.Containers {
|
||||
createdTimes := make(map[string]time.Time, len(allContainers))
|
||||
for _, ctr := range allContainers {
|
||||
createdTimes[ctr.Container.ID], _ = time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
||||
}
|
||||
|
||||
// Sort containers by created time (newest first).
|
||||
slices.SortFunc(svc.Containers, func(a, b api.MachineServiceContainer) int {
|
||||
slices.SortFunc(allContainers, func(a, b api.MachineServiceContainer) int {
|
||||
return createdTimes[b.Container.ID].Compare(createdTimes[a.Container.ID])
|
||||
})
|
||||
|
||||
// Print the list of containers in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tIP ADDRESS\tMACHINE"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
// Show HOOK column only when hook containers are present.
|
||||
hasHooks := len(svc.HookContainers) > 0
|
||||
|
||||
t := tui.NewTable()
|
||||
if hasHooks {
|
||||
t.Headers("CONTAINER ID", "IMAGE", "CREATED", "STATUS", "HOOK", "IP ADDRESS", "MACHINE")
|
||||
} else {
|
||||
t.Headers("CONTAINER ID", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, ctr := range svc.Containers {
|
||||
for _, ctr := range allContainers {
|
||||
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
|
||||
|
||||
machine := machinesNamesByID[ctr.MachineID]
|
||||
@@ -98,19 +105,28 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||
ipStr = ip.String()
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(
|
||||
tw,
|
||||
"%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
stringid.TruncateID(ctr.Container.ID),
|
||||
ctr.Container.Config.Image,
|
||||
created,
|
||||
state,
|
||||
ipStr,
|
||||
machine,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
if hasHooks {
|
||||
t.Row(
|
||||
stringid.TruncateID(ctr.Container.ID),
|
||||
tui.FormatImage(ctr.Container.Config.Image, tui.NoStyle),
|
||||
created,
|
||||
state,
|
||||
ctr.Container.Config.Labels[api.LabelHook],
|
||||
ipStr,
|
||||
machine,
|
||||
)
|
||||
} else {
|
||||
t.Row(
|
||||
stringid.TruncateID(ctr.Container.ID),
|
||||
tui.FormatImage(ctr.Container.Config.Image, tui.NoStyle),
|
||||
created,
|
||||
state,
|
||||
ipStr,
|
||||
machine,
|
||||
)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
|
||||
fmt.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
+18
-19
@@ -3,12 +3,11 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -50,20 +49,22 @@ func list(ctx context.Context, uncli *cli.CLI) error {
|
||||
})
|
||||
|
||||
// Print the list of services in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
t := tui.NewTable()
|
||||
|
||||
// Include the ID column if there are duplicate service names to differentiate them.
|
||||
headers := []string{"NAME", "MODE", "REPLICAS", "IMAGE", "ENDPOINTS"}
|
||||
if haveDuplicateNames {
|
||||
if _, err = fmt.Fprintf(tw, "ID\t"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tIMAGE\tENDPOINTS"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
headers = append([]string{"ID"}, headers...)
|
||||
}
|
||||
t.Headers(headers...)
|
||||
|
||||
for _, s := range services {
|
||||
images := strings.Join(s.Images(), ", ")
|
||||
endpoints := strings.Join(s.Endpoints(), ", ")
|
||||
images := s.Images()
|
||||
for i, img := range images {
|
||||
images[i] = tui.FormatImage(img, tui.NoStyle)
|
||||
}
|
||||
formattedImages := strings.Join(images, tui.Faint.Render(", "))
|
||||
endpoints := strings.Join(s.Endpoints(), tui.Faint.Render(", "))
|
||||
|
||||
// If no endpoints from ports, check if the service uses custom Caddy config.
|
||||
if endpoints == "" {
|
||||
@@ -74,15 +75,13 @@ func list(ctx context.Context, uncli *cli.CLI) error {
|
||||
}
|
||||
}
|
||||
|
||||
row := []string{s.Name, s.Mode, fmt.Sprintf("%d", len(s.Containers)), formattedImages, endpoints}
|
||||
if haveDuplicateNames {
|
||||
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n",
|
||||
s.Name, s.Mode, len(s.Containers), images, endpoints); err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
row = append([]string{s.ID}, row...)
|
||||
}
|
||||
t.Row(row...)
|
||||
}
|
||||
return tw.Flush()
|
||||
|
||||
fmt.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ package volume
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -86,16 +85,13 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||
}
|
||||
|
||||
// Print the volumes in a table format.
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
fmt.Fprintln(tw, "NAME\tDRIVER\tMACHINE")
|
||||
t := tui.NewTable()
|
||||
t.Headers("NAME", "DRIVER", "MACHINE")
|
||||
|
||||
for _, v := range volumes {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n",
|
||||
v.Volume.Name,
|
||||
v.Volume.Driver,
|
||||
v.MachineName,
|
||||
)
|
||||
t.Row(v.Volume.Name, v.Volume.Driver, v.MachineName)
|
||||
}
|
||||
|
||||
return tw.Flush()
|
||||
fmt.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
+8
-14
@@ -3,13 +3,12 @@ package wg
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/docker/go-units"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -97,10 +96,8 @@ func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
if _, err = fmt.Fprintln(tw, "PEER\tPUBLIC KEY\tENDPOINT\tHANDSHAKE\tRECEIVED\tSENT\tALLOWED IPS"); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
t := tui.NewTable()
|
||||
t.Headers("PEER", "PUBLIC KEY", "ENDPOINT", "HANDSHAKE", "RECEIVED", "SENT", "ALLOWED IPS")
|
||||
|
||||
for _, peer := range resp.Peers {
|
||||
machineName, ok := machinesNamesByPublicKey[wgtypes.Key(peer.PublicKey).String()]
|
||||
@@ -113,20 +110,17 @@ func runShow(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
||||
lastHandshake = time.Since(peer.LastHandshakeTime.AsTime()).Round(time.Second).String() + " ago"
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(
|
||||
tw,
|
||||
"%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
t.Row(
|
||||
machineName,
|
||||
wgtypes.Key(peer.PublicKey).String(),
|
||||
peer.Endpoint,
|
||||
lastHandshake,
|
||||
units.HumanSize(float64(peer.ReceiveBytes)),
|
||||
units.HumanSize(float64(peer.TransmitBytes)),
|
||||
strings.Join(peer.AllowedIps, ", "),
|
||||
strings.Join(peer.AllowedIps, tui.Faint.Render(", ")),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write row: %w", err)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
|
||||
fmt.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
github.com/caddyserver/caddy/v2 v2.8.4
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/charmbracelet/colorprofile v0.4.2
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/compose-spec/compose-go/v2 v2.9.0
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/containerd/platforms v1.0.0-rc.1
|
||||
@@ -111,7 +112,6 @@ require (
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
|
||||
+52
-50
@@ -263,8 +263,8 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||
MachineID: resp.Machine.Id,
|
||||
}
|
||||
if opts.RemoteMachine.UseSSHCLI {
|
||||
connCfg.SSHCLI = config.NewSSHDestination(
|
||||
if opts.RemoteMachine.UseSSHGo {
|
||||
connCfg.SSHGo = config.NewSSHDestination(
|
||||
opts.RemoteMachine.User,
|
||||
opts.RemoteMachine.Host,
|
||||
opts.RemoteMachine.Port,
|
||||
@@ -467,8 +467,8 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||
MachineID: addResp.Machine.Id,
|
||||
}
|
||||
if opts.RemoteMachine.UseSSHCLI {
|
||||
connCfg.SSHCLI = config.NewSSHDestination(
|
||||
if opts.RemoteMachine.UseSSHGo {
|
||||
connCfg.SSHGo = config.NewSSHDestination(
|
||||
opts.RemoteMachine.User,
|
||||
opts.RemoteMachine.Host,
|
||||
opts.RemoteMachine.Port,
|
||||
@@ -498,73 +498,75 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
||||
func provisionOrConnectRemoteMachine(
|
||||
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
|
||||
) (*client.Client, error) {
|
||||
// Use SSH CLI
|
||||
if remoteMachine.UseSSHCLI {
|
||||
exec := sshexec.NewSSHCLIRemote(
|
||||
remoteMachine.User,
|
||||
remoteMachine.Host,
|
||||
remoteMachine.Port,
|
||||
remoteMachine.KeyPath,
|
||||
// Use Go's built-in SSH library.
|
||||
if remoteMachine.UseSSHGo {
|
||||
sshClient, err := sshexec.Connect(
|
||||
remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath,
|
||||
)
|
||||
// If the SSH connection using SSH agent fails and no key path is provided, try to use the default SSH key.
|
||||
if err != nil && remoteMachine.KeyPath == "" {
|
||||
remoteMachine.KeyPath = DefaultSSHKeyPath
|
||||
sshClient, err = sshexec.Connect(
|
||||
remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"SSH login to remote machine %s: %w",
|
||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
||||
)
|
||||
}
|
||||
|
||||
if !skipInstall {
|
||||
if err := provisionMachine(ctx, exec, version); err != nil {
|
||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||
exec := sshexec.NewRemote(sshClient)
|
||||
if err = provisionMachine(ctx, exec, version); err != nil {
|
||||
return nil, fmt.Errorf("provision machine: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: remoteMachine.User,
|
||||
Host: remoteMachine.Host,
|
||||
Port: remoteMachine.Port,
|
||||
KeyPath: remoteMachine.KeyPath,
|
||||
var machineClient *client.Client
|
||||
if remoteMachine.User == "root" || skipInstall {
|
||||
// Create a machine API client over the established SSH connection to the remote machine.
|
||||
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
||||
} else {
|
||||
// Since the user is not root, we need to establish a new SSH connection to make the user's addition
|
||||
// to the uncloud group effective, thus allowing access to the Uncloud daemon Unix socket.
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: remoteMachine.User,
|
||||
Host: remoteMachine.Host,
|
||||
Port: remoteMachine.Port,
|
||||
KeyPath: remoteMachine.KeyPath,
|
||||
}
|
||||
machineClient, err = client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||
}
|
||||
machineClient, err := client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to remote machine: %w", err)
|
||||
}
|
||||
return machineClient, nil
|
||||
}
|
||||
|
||||
// Use Go SSH
|
||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
||||
// If the SSH connection using SSH agent fails and no key path is provided, try to use the default SSH key.
|
||||
if err != nil && remoteMachine.KeyPath == "" {
|
||||
remoteMachine.KeyPath = DefaultSSHKeyPath
|
||||
sshClient, err = sshexec.Connect(
|
||||
remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"SSH login to remote machine %s: %w",
|
||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
||||
)
|
||||
}
|
||||
// Use the system 'ssh' command (default).
|
||||
exec := sshexec.NewSSHCLIRemote(
|
||||
remoteMachine.User,
|
||||
remoteMachine.Host,
|
||||
remoteMachine.Port,
|
||||
remoteMachine.KeyPath,
|
||||
)
|
||||
|
||||
if !skipInstall {
|
||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||
exec := sshexec.NewRemote(sshClient)
|
||||
if err = provisionMachine(ctx, exec, version); err != nil {
|
||||
if err := provisionMachine(ctx, exec, version); err != nil {
|
||||
return nil, fmt.Errorf("provision machine: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var machineClient *client.Client
|
||||
if remoteMachine.User == "root" || skipInstall {
|
||||
// Create a machine API client over the established SSH connection to the remote machine.
|
||||
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
||||
} else {
|
||||
// Since the user is not root, we need to establish a new SSH connection to make the user's addition
|
||||
// to the uncloud group effective, thus allowing access to the Uncloud daemon Unix socket.
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: remoteMachine.User,
|
||||
Host: remoteMachine.Host,
|
||||
Port: remoteMachine.Port,
|
||||
KeyPath: remoteMachine.KeyPath,
|
||||
}
|
||||
machineClient, err = client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: remoteMachine.User,
|
||||
Host: remoteMachine.Host,
|
||||
Port: remoteMachine.Port,
|
||||
KeyPath: remoteMachine.KeyPath,
|
||||
}
|
||||
machineClient, err := client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to remote machine: %w", err)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,12 @@ import (
|
||||
)
|
||||
|
||||
type MachineConnection struct {
|
||||
SSH SSHDestination `yaml:"ssh,omitempty"`
|
||||
SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"`
|
||||
// SSH uses the system ssh CLI command to connect. This is the default SSH connection method.
|
||||
SSH SSHDestination `yaml:"ssh,omitempty"`
|
||||
// SSHCLI is a backward-compatible alias for SSH.
|
||||
SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"`
|
||||
// SSHGo uses Go's built-in SSH library to connect.
|
||||
SSHGo SSHDestination `yaml:"ssh_go,omitempty"`
|
||||
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
|
||||
// TCP is the address and port of the machine's API server.
|
||||
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
|
||||
@@ -29,7 +33,9 @@ func (c *MachineConnection) String() string {
|
||||
if c.SSH != "" {
|
||||
return "ssh://" + string(c.SSH)
|
||||
} else if c.SSHCLI != "" {
|
||||
return "ssh+cli://" + string(c.SSHCLI)
|
||||
return "ssh://" + string(c.SSHCLI)
|
||||
} else if c.SSHGo != "" {
|
||||
return "ssh+go://" + string(c.SSHGo)
|
||||
} else if c.TCP != nil && c.TCP.IsValid() {
|
||||
return fmt.Sprintf("tcp://%s", c.TCP)
|
||||
} else if c.Unix != "" {
|
||||
@@ -46,6 +52,9 @@ func (c *MachineConnection) Validate() error {
|
||||
if c.SSHCLI != "" {
|
||||
setCount++
|
||||
}
|
||||
if c.SSHGo != "" {
|
||||
setCount++
|
||||
}
|
||||
if c.TCP != nil && c.TCP.IsValid() {
|
||||
setCount++
|
||||
}
|
||||
@@ -54,10 +63,10 @@ func (c *MachineConnection) Validate() error {
|
||||
}
|
||||
|
||||
if setCount == 0 {
|
||||
return errors.New("no connection method specified (ssh, ssh_cli, tcp, or unix required)")
|
||||
return errors.New("no connection method specified (ssh, ssh_go, tcp, or unix required)")
|
||||
}
|
||||
if setCount > 1 {
|
||||
return errors.New("only one connection method allowed per connection (ssh, ssh_cli, tcp, or unix)")
|
||||
return errors.New("only one connection method allowed per connection (ssh, ssh_go, tcp, or unix)")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -30,18 +30,32 @@ func TestMachineConnection_String(t *testing.T) {
|
||||
want: "ssh://user@host.com:2222",
|
||||
},
|
||||
{
|
||||
name: "ssh_cli connection",
|
||||
name: "ssh_cli connection (backward compat alias for ssh)",
|
||||
conn: MachineConnection{
|
||||
SSHCLI: "user@host.com",
|
||||
},
|
||||
want: "ssh+cli://user@host.com",
|
||||
want: "ssh://user@host.com",
|
||||
},
|
||||
{
|
||||
name: "ssh_cli connection with port",
|
||||
name: "ssh_cli connection with port (backward compat alias for ssh)",
|
||||
conn: MachineConnection{
|
||||
SSHCLI: "user@host.com:2222",
|
||||
},
|
||||
want: "ssh+cli://user@host.com:2222",
|
||||
want: "ssh://user@host.com:2222",
|
||||
},
|
||||
{
|
||||
name: "ssh_go connection",
|
||||
conn: MachineConnection{
|
||||
SSHGo: "user@host.com",
|
||||
},
|
||||
want: "ssh+go://user@host.com",
|
||||
},
|
||||
{
|
||||
name: "ssh_go connection with port",
|
||||
conn: MachineConnection{
|
||||
SSHGo: "user@host.com:2222",
|
||||
},
|
||||
want: "ssh+go://user@host.com:2222",
|
||||
},
|
||||
{
|
||||
name: "tcp connection",
|
||||
@@ -103,12 +117,19 @@ func TestMachineConnection_Validate(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ssh_cli only - valid",
|
||||
name: "ssh_cli only - valid (backward compat)",
|
||||
conn: MachineConnection{
|
||||
SSHCLI: "user@host",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ssh_go only - valid",
|
||||
conn: MachineConnection{
|
||||
SSHGo: "user@host",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "tcp only - valid",
|
||||
conn: MachineConnection{
|
||||
@@ -141,6 +162,15 @@ func TestMachineConnection_Validate(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "only one connection method allowed",
|
||||
},
|
||||
{
|
||||
name: "ssh and ssh_go - error",
|
||||
conn: MachineConnection{
|
||||
SSH: "user@host",
|
||||
SSHGo: "user@host",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "only one connection method allowed",
|
||||
},
|
||||
{
|
||||
name: "ssh and unix - error",
|
||||
conn: MachineConnection{
|
||||
|
||||
+12
-8
@@ -57,9 +57,9 @@ func connectClusterWithProgress(ctx context.Context, conn config.MachineConnecti
|
||||
}
|
||||
|
||||
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||
// Determine which SSH type is configured
|
||||
// Determine which SSH type is configured.
|
||||
var sshDest config.SSHDestination
|
||||
var useSSHCLI bool
|
||||
var useGoSSH bool
|
||||
|
||||
// Validate connection configuration early to provide clear error messages.
|
||||
if err := conn.Validate(); err != nil {
|
||||
@@ -67,11 +67,15 @@ func connectCluster(ctx context.Context, conn config.MachineConnection) (*client
|
||||
}
|
||||
|
||||
if conn.SSH != "" {
|
||||
// SSH uses the system ssh CLI command (default).
|
||||
sshDest = conn.SSH
|
||||
useSSHCLI = false
|
||||
} else if conn.SSHCLI != "" {
|
||||
// SSHCLI is a backward-compatible alias for SSH.
|
||||
sshDest = conn.SSHCLI
|
||||
useSSHCLI = true
|
||||
} else if conn.SSHGo != "" {
|
||||
// SSHGo uses Go's built-in SSH library.
|
||||
sshDest = conn.SSHGo
|
||||
useGoSSH = true
|
||||
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||
} else if conn.Unix != "" {
|
||||
@@ -95,11 +99,11 @@ func connectCluster(ctx context.Context, conn config.MachineConnection) (*client
|
||||
KeyPath: keyPath,
|
||||
}
|
||||
|
||||
// Create appropriate connector based on type
|
||||
if useSSHCLI {
|
||||
return client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
|
||||
// Create appropriate connector based on type.
|
||||
if useGoSSH {
|
||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||
}
|
||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||
return client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
|
||||
}
|
||||
|
||||
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
|
||||
|
||||
@@ -23,11 +23,11 @@ const (
|
||||
)
|
||||
|
||||
type RemoteMachine struct {
|
||||
User string
|
||||
Host string
|
||||
Port int
|
||||
KeyPath string
|
||||
UseSSHCLI bool // indicates ssh+cli:// should be used
|
||||
User string
|
||||
Host string
|
||||
Port int
|
||||
KeyPath string
|
||||
UseSSHGo bool // Use Go's built-in SSH library instead of the system ssh CLI command.
|
||||
}
|
||||
|
||||
func installCmd(user string, version string) string {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
)
|
||||
|
||||
type eventIDKey struct{}
|
||||
|
||||
// WithEventID returns a context that overrides the default event ID used by client methods.
|
||||
func WithEventID(ctx context.Context, eventID string) context.Context {
|
||||
return context.WithValue(ctx, eventIDKey{}, eventID)
|
||||
}
|
||||
|
||||
// ContainerEventID returns a progress event ID for operations on existing containers using the canonical
|
||||
// service_name/short_id format. Allows to override it using WithEventID.
|
||||
func ContainerEventID(ctx context.Context, serviceName, containerID, machineName string) string {
|
||||
if id, ok := ctx.Value(eventIDKey{}).(string); ok && id != "" {
|
||||
return id
|
||||
}
|
||||
return tui.Faint.Render("Container ") +
|
||||
serviceName + tui.Faint.Render("/") + stringid.TruncateID(containerID) +
|
||||
tui.Faint.Render(" on ") + machineName
|
||||
}
|
||||
|
||||
// NewContainerEventID returns a progress event ID for new container creation where the Docker container ID
|
||||
// is not yet known. Allows to override it using WithEventID.
|
||||
func NewContainerEventID(ctx context.Context, containerName, machineName string) string {
|
||||
if id, ok := ctx.Value(eventIDKey{}).(string); ok && id != "" {
|
||||
return id
|
||||
}
|
||||
return tui.Faint.Render("Container ") + containerName +
|
||||
tui.Faint.Render(" on ") + machineName
|
||||
}
|
||||
|
||||
// PreDeployHookEventID returns a progress event ID for pre-deploy hook operations.
|
||||
func PreDeployHookEventID(serviceName, machineName string) string {
|
||||
return tui.Faint.Render("Pre-deploy hook ") + serviceName +
|
||||
tui.Faint.Render(" on ") + machineName
|
||||
}
|
||||
|
||||
// OldPreDeployHookEventID returns a progress event ID for old pre-deploy hook container cleanup.
|
||||
func OldPreDeployHookEventID(serviceName, containerID, machineName string) string {
|
||||
return tui.Faint.Render("Old pre-deploy hook ") +
|
||||
serviceName + tui.Faint.Render("/") + stringid.TruncateID(containerID) +
|
||||
tui.Faint.Render(" on ") + machineName
|
||||
}
|
||||
|
||||
// ImageEventID returns a progress event ID for image pull operations.
|
||||
func ImageEventID(image, machineName string) string {
|
||||
return tui.Faint.Render("Image ") + image +
|
||||
tui.Faint.Render(" on ") + machineName
|
||||
}
|
||||
|
||||
// VolumeEventID returns a progress event ID for volume operations.
|
||||
func VolumeEventID(volumeName, machineName string) string {
|
||||
return tui.Faint.Render("Volume ") + volumeName +
|
||||
tui.Faint.Render(" on ") + machineName
|
||||
}
|
||||
|
||||
// MachineEventID returns a progress event ID for machine operations.
|
||||
func MachineEventID(machineName, publicIP string) string {
|
||||
return tui.Faint.Render("Machine ") + machineName +
|
||||
tui.Faint.Render(" (") + publicIP + tui.Faint.Render(")")
|
||||
}
|
||||
@@ -51,3 +51,13 @@ func IsStdinTerminal() bool {
|
||||
func IsStdoutTerminal() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// TerminalWidth returns the width of the terminal.
|
||||
// Returns 0 if stdout is not a terminal or the width cannot be determined.
|
||||
func TerminalWidth() int {
|
||||
width, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || width <= 0 {
|
||||
return 0
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
NoStyle = lipgloss.NewStyle()
|
||||
|
||||
Faint = lipgloss.NewStyle().Faint(true)
|
||||
Red = lipgloss.NewStyle().Foreground(lipgloss.Red)
|
||||
Green = lipgloss.NewStyle().Foreground(lipgloss.Green)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
)
|
||||
|
||||
// NewTable creates a borderless table with bold headers and consistent padding for CLI output.
|
||||
func NewTable() *table.Table {
|
||||
return table.New().
|
||||
Border(lipgloss.Border{}).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderLeft(false).
|
||||
BorderRight(false).
|
||||
BorderHeader(false).
|
||||
BorderColumn(false).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
if row == table.HeaderRow {
|
||||
return Bold.PaddingRight(3)
|
||||
}
|
||||
return NoStyle.PaddingRight(3)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package grpcversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/psviderski/uncloud/internal/version"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataKeyClientVersion = "uncloud-client-version"
|
||||
MetadataKeyMinServerVersion = "uncloud-min-server-version"
|
||||
MetadataKeyServerVersion = "uncloud-server-version"
|
||||
|
||||
// MinClientVersion is the minimum client version the daemon accepts. The daemon
|
||||
// rejects requests from older clients, forcing them to upgrade. This provides
|
||||
// a clean cut-off for dropping support for old clients.
|
||||
//
|
||||
// MinServerVersion is the minimum daemon version the client requires. The client
|
||||
// sends this with each request so the daemon can immediately reject if it's too old,
|
||||
// avoiding the need for a preflight request. This is useful when a new client feature
|
||||
// requires daemon capabilities that didn't exist in older versions.
|
||||
//
|
||||
// The two minimums are independent: a client might require a newer daemon for new
|
||||
// features, while that same daemon could still handle requests from older clients.
|
||||
MinClientVersion = "0.0.0"
|
||||
MinServerVersion = "0.0.0"
|
||||
|
||||
ReleaseURL = "https://github.com/psviderski/uncloud/releases/latest"
|
||||
)
|
||||
|
||||
var (
|
||||
// currentVersion is the version of this binary (CLI or daemon).
|
||||
currentVersion = semver.MustParse(version.String())
|
||||
// zeroVersion is used when no version is specified (treated as 0.0.0).
|
||||
zeroVersion = semver.MustParse("0.0.0")
|
||||
// Pre-parsed minimum versions for comparison.
|
||||
minClientVersion = semver.MustParse(MinClientVersion)
|
||||
minServerVersion = semver.MustParse(MinServerVersion)
|
||||
|
||||
// warned tracks if we've already printed the daemon version warning.
|
||||
// TODO: Remove when checkServerVersionInResponse is no longer needed (see below).
|
||||
warned atomic.Bool
|
||||
|
||||
// WarnWriter is the writer used for version mismatch warnings. Defaults to os.Stderr.
|
||||
// Tests can override this to capture warning output.
|
||||
WarnWriter io.Writer = os.Stderr
|
||||
)
|
||||
|
||||
func extractVersion(md metadata.MD, key string) *semver.Version {
|
||||
if md == nil {
|
||||
return zeroVersion
|
||||
}
|
||||
values := md.Get(key)
|
||||
if len(values) == 0 || values[0] == "" {
|
||||
return zeroVersion
|
||||
}
|
||||
sv, err := semver.NewVersion(values[0])
|
||||
if err != nil {
|
||||
return zeroVersion
|
||||
}
|
||||
return sv
|
||||
}
|
||||
|
||||
func checkClientVersionHeaders(ctx context.Context) error {
|
||||
md, _ := metadata.FromIncomingContext(ctx)
|
||||
|
||||
actualClientVersion := extractVersion(md, MetadataKeyClientVersion)
|
||||
if actualClientVersion.LessThan(minClientVersion) {
|
||||
return status.Errorf(codes.FailedPrecondition,
|
||||
"version check failed: client version is below minimum %s. Please upgrade: %s",
|
||||
minClientVersion, ReleaseURL)
|
||||
}
|
||||
|
||||
requiredMinServer := extractVersion(md, MetadataKeyMinServerVersion)
|
||||
if currentVersion.LessThan(requiredMinServer) {
|
||||
return status.Errorf(codes.FailedPrecondition,
|
||||
"version check failed: daemon version %s is below client's minimum required version %s. Please upgrade the daemon: %s",
|
||||
currentVersion, requiredMinServer, ReleaseURL)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ServerUnaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
if err := checkClientVersionHeaders(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := grpc.SetHeader(ctx, metadata.Pairs(MetadataKeyServerVersion, currentVersion.String())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
func ServerStreamInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
if err := checkClientVersionHeaders(ss.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ss.SetHeader(metadata.Pairs(MetadataKeyServerVersion, currentVersion.String())); err != nil {
|
||||
return err
|
||||
}
|
||||
return handler(srv, ss)
|
||||
}
|
||||
|
||||
func ClientUnaryInterceptor(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx,
|
||||
MetadataKeyClientVersion, currentVersion.String(),
|
||||
MetadataKeyMinServerVersion, MinServerVersion,
|
||||
)
|
||||
|
||||
// TODO: Remove when checkServerVersionInResponse is no longer needed,
|
||||
// as we'll no longer need to extract headers from the response here.
|
||||
var respMD metadata.MD
|
||||
opts = append(opts, grpc.Header(&respMD))
|
||||
|
||||
err := invoker(ctx, method, req, reply, cc, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: Remove eventually (see note on method below).
|
||||
checkServerVersionInResponse(respMD)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkServerVersionInResponse warns the user when they communicated with a daemon that
|
||||
// did not check the version requirements. This is only needed during the transition to
|
||||
// version-checking releases.
|
||||
// TODO: Remove this in some later release, after users have upgraded.
|
||||
func checkServerVersionInResponse(md metadata.MD) {
|
||||
serverVersion := extractVersion(md, MetadataKeyServerVersion)
|
||||
if serverVersion.LessThan(minServerVersion) {
|
||||
if warned.Swap(true) {
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("daemon version is below minimum required version %s. The daemon did not verify this CLI's minimum version requirement, so the operation may not have behaved as intended. Please upgrade the daemon: %s",
|
||||
minServerVersion, ReleaseURL)
|
||||
fmt.Fprintf(WarnWriter, "WARNING: %s\n", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func ClientStreamInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx,
|
||||
MetadataKeyClientVersion, currentVersion.String(),
|
||||
MetadataKeyMinServerVersion, MinServerVersion,
|
||||
)
|
||||
|
||||
stream, err := streamer(ctx, desc, cc, method, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: Wrapping the stream in versionedClientStream will no longer
|
||||
// be necessary when we are ready to remove the temporary, transition
|
||||
// safety check checkServerVersionInResponse (see note on method above).
|
||||
return &versionedClientStream{ClientStream: stream}, nil
|
||||
}
|
||||
|
||||
// TODO: Remove when checkServerVersionInResponse is no longer needed.
|
||||
type versionedClientStream struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
// TODO: Remove when checkServerVersionInResponse is no longer needed.
|
||||
func (s *versionedClientStream) Header() (metadata.MD, error) {
|
||||
md, err := s.ClientStream.Header()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checkServerVersionInResponse(md)
|
||||
|
||||
return md, nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package grpcversion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestExtractVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
md metadata.MD
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "nil metadata",
|
||||
md: nil,
|
||||
key: MetadataKeyClientVersion,
|
||||
expected: "0.0.0",
|
||||
},
|
||||
{
|
||||
name: "missing key",
|
||||
md: metadata.MD{},
|
||||
key: MetadataKeyClientVersion,
|
||||
expected: "0.0.0",
|
||||
},
|
||||
{
|
||||
name: "empty value",
|
||||
md: metadata.Pairs(MetadataKeyClientVersion, ""),
|
||||
key: MetadataKeyClientVersion,
|
||||
expected: "0.0.0",
|
||||
},
|
||||
{
|
||||
name: "invalid version",
|
||||
md: metadata.Pairs(MetadataKeyClientVersion, "not-a-version"),
|
||||
key: MetadataKeyClientVersion,
|
||||
expected: "0.0.0",
|
||||
},
|
||||
{
|
||||
name: "valid version",
|
||||
md: metadata.Pairs(MetadataKeyClientVersion, "1.2.3"),
|
||||
key: MetadataKeyClientVersion,
|
||||
expected: "1.2.3",
|
||||
},
|
||||
{
|
||||
name: "version with prerelease",
|
||||
md: metadata.Pairs(MetadataKeyClientVersion, "0.0.0-dev"),
|
||||
key: MetadataKeyClientVersion,
|
||||
expected: "0.0.0-dev",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractVersion(tt.md, tt.key)
|
||||
assert.Equal(t, tt.expected, got.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckClientVersionHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
md metadata.MD
|
||||
wantErr bool
|
||||
errCode codes.Code
|
||||
errContain string
|
||||
}{
|
||||
{
|
||||
name: "cli version below minimum",
|
||||
md: metadata.Pairs(
|
||||
MetadataKeyClientVersion, "0.0.0-dev",
|
||||
),
|
||||
wantErr: true,
|
||||
errCode: codes.FailedPrecondition,
|
||||
errContain: "client version is below minimum",
|
||||
},
|
||||
{
|
||||
name: "cli version above minimum",
|
||||
md: metadata.Pairs(
|
||||
MetadataKeyClientVersion, "999.0.0",
|
||||
),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "min daemon version above current daemon",
|
||||
md: metadata.Pairs(
|
||||
MetadataKeyClientVersion, "999.0.0",
|
||||
MetadataKeyMinServerVersion, "999.0.0",
|
||||
),
|
||||
wantErr: true,
|
||||
errCode: codes.FailedPrecondition,
|
||||
errContain: "daemon version",
|
||||
},
|
||||
{
|
||||
name: "min daemon version below current daemon",
|
||||
md: metadata.Pairs(
|
||||
MetadataKeyClientVersion, "999.0.0",
|
||||
MetadataKeyMinServerVersion, "0.0.1",
|
||||
),
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if tt.md != nil {
|
||||
ctx = metadata.NewIncomingContext(ctx, tt.md)
|
||||
}
|
||||
|
||||
err := checkClientVersionHeaders(ctx)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok, "expected gRPC status error, got %T", err)
|
||||
assert.Equal(t, tt.errCode, st.Code())
|
||||
assert.Contains(t, st.Message(), tt.errContain)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func captureWarnings(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
old := WarnWriter
|
||||
WarnWriter = &buf
|
||||
t.Cleanup(func() { WarnWriter = old })
|
||||
fn()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestCheckServerVersionInResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
md metadata.MD
|
||||
wantWarning bool
|
||||
}{
|
||||
{
|
||||
name: "daemon version below minimum",
|
||||
md: metadata.Pairs(MetadataKeyServerVersion, "0.0.0-dev"),
|
||||
wantWarning: true,
|
||||
},
|
||||
{
|
||||
name: "daemon version above minimum",
|
||||
md: metadata.Pairs(MetadataKeyServerVersion, "999.0.0"),
|
||||
wantWarning: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
warned.Store(false)
|
||||
|
||||
output := captureWarnings(t, func() {
|
||||
checkServerVersionInResponse(tt.md)
|
||||
})
|
||||
|
||||
if tt.wantWarning {
|
||||
assert.Contains(t, output, "WARNING")
|
||||
} else {
|
||||
assert.Empty(t, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckServerVersionInResponse_WarnOnce(t *testing.T) {
|
||||
warned.Store(false)
|
||||
|
||||
md := metadata.Pairs(MetadataKeyServerVersion, "0.0.0-dev")
|
||||
|
||||
// First call should warn.
|
||||
output1 := captureWarnings(t, func() {
|
||||
checkServerVersionInResponse(md)
|
||||
})
|
||||
assert.Contains(t, output1, "WARNING", "first call should warn")
|
||||
|
||||
// Second call should not warn (warned flag is now true).
|
||||
output2 := captureWarnings(t, func() {
|
||||
checkServerVersionInResponse(md)
|
||||
})
|
||||
assert.Empty(t, output2, "second call should not warn")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package journal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
const journalctl = "journalctl"
|
||||
|
||||
var commandContext = exec.CommandContext // overidable for the test
|
||||
|
||||
func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.ReadCloser, error) {
|
||||
args := []string{"-u", unit, "--no-hostname"}
|
||||
args = append(args, "-n")
|
||||
if opts.Tail > -1 {
|
||||
args = append(args, fmt.Sprintf("%d", opts.Tail))
|
||||
} else {
|
||||
args = append(args, "all")
|
||||
}
|
||||
if opts.Follow {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
|
||||
args = append(args, "-o")
|
||||
args = append(args, "short-iso-precise")
|
||||
|
||||
if opts.Since != "" {
|
||||
args = append(args, "-S")
|
||||
args = append(args, opts.Since)
|
||||
}
|
||||
if opts.Until != "" {
|
||||
args = append(args, "-U")
|
||||
args = append(args, opts.Until)
|
||||
}
|
||||
|
||||
cmd := commandContext(ctx, journalctl, args...)
|
||||
p, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// follow synchronously follows the io.Reader, writing each new journal entry to channel.
|
||||
// It stops when the reader is exhausted or the context is cancelled.
|
||||
func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case outCh <- entry(scanner.Bytes()):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
outCh <- api.LogEntry{Err: fmt.Errorf("journal logs: %w", err)}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package journal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
// Logs streams logs from a service and returns entries via a channel.
|
||||
func Logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (<-chan api.LogEntry, error) {
|
||||
// Hard code unit check for now
|
||||
switch unit {
|
||||
case "uncloud":
|
||||
case "uncloud-corrosion":
|
||||
case "docker":
|
||||
default:
|
||||
return nil, fmt.Errorf("journal logs: invalid unit: %s", unit)
|
||||
}
|
||||
|
||||
reader, err := logs(ctx, unit, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outCh := make(chan api.LogEntry)
|
||||
|
||||
go func() {
|
||||
defer close(outCh)
|
||||
follow(ctx, reader, outCh)
|
||||
}()
|
||||
|
||||
return outCh, nil
|
||||
}
|
||||
|
||||
func entry(data []byte) api.LogEntry {
|
||||
// 2025-10-12T11:03:27+02:00 systemd[1]:
|
||||
timestamp := time.Time{}
|
||||
message := data
|
||||
if len(data) > 30 && data[4] == '-' && data[7] == '-' && data[10] == 'T' {
|
||||
timestampPart, messagePart, found := bytes.Cut(data, []byte(" "))
|
||||
var err error
|
||||
if found {
|
||||
timestamp, err = time.Parse(time.RFC3339Nano, string(timestampPart))
|
||||
if err != nil {
|
||||
timestamp = time.Time{}
|
||||
}
|
||||
message = messagePart
|
||||
}
|
||||
}
|
||||
|
||||
return api.LogEntry{
|
||||
Timestamp: timestamp,
|
||||
Message: slices.Clone(message), // scanner controls the buffer
|
||||
Stream: api.LogStreamStdout,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package journal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLogs(t *testing.T) {
|
||||
commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
ch, err := Logs(ctx, "uncloud", api.ServiceLogsOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
i := 0
|
||||
for range ch {
|
||||
i++
|
||||
}
|
||||
assert.Equal(t, 6, i)
|
||||
|
||||
commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "/usr/bin/tail", "-f", "testdata/logs")
|
||||
}
|
||||
|
||||
ctx = context.Background()
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
go func() { time.Sleep(1 * time.Second); cancel() }()
|
||||
|
||||
ch, err = Logs(ctx, "uncloud", api.ServiceLogsOptions{Tail: 3})
|
||||
require.NoError(t, err)
|
||||
|
||||
i = 0
|
||||
for range ch {
|
||||
i++
|
||||
}
|
||||
// Still six because heartbeats are not written here and Tail is ignored as the command is overridden.
|
||||
assert.Equal(t, 6, i)
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
2026-01-23T17:19:33.686964+01:00 fedora kernel: apple-dcp 271c00000.dcp: DCP index:1 dptx target phy: 5 dptx die: 0
|
||||
2026-01-23T17:19:33.687155+01:00 fedora kernel: platform 271c00000.dcp:piodma: Adding to iommu group 9
|
||||
2026-01-23T17:19:33.687343+01:00 fedora kernel: apple-dcp 271c00000.dcp: RTKit: Initializing (protocol version 12)
|
||||
2026-01-23T17:19:33.687500+01:00 fedora kernel: apple-dcp 271c00000.dcp: shmem_setup: iova: 880255000 -> pa: be4f29000 -> iomem: ffff800082>
|
||||
2026-01-23T17:19:33.687657+01:00 fedora kernel: apple-dcp 271c00000.dcp: shmem_setup: iova: ffffec000, buffer: ffff8000817cc000
|
||||
2026-01-23T17:19:33.687826+01:00 fedora kernel: apple-dcp 271c00000.dcp: shmem_setup: iova: ffffe8000, buffer: ffff8000817d4000
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
status "google.golang.org/genproto/googleapis/rpc/status"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
@@ -21,6 +22,58 @@ const (
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type LogEntry_StreamType int32
|
||||
|
||||
const (
|
||||
LogEntry_UNKNOWN LogEntry_StreamType = 0
|
||||
LogEntry_STDOUT LogEntry_StreamType = 1
|
||||
LogEntry_STDERR LogEntry_StreamType = 2
|
||||
LogEntry_HEARTBEAT LogEntry_StreamType = 3
|
||||
)
|
||||
|
||||
// Enum value maps for LogEntry_StreamType.
|
||||
var (
|
||||
LogEntry_StreamType_name = map[int32]string{
|
||||
0: "UNKNOWN",
|
||||
1: "STDOUT",
|
||||
2: "STDERR",
|
||||
3: "HEARTBEAT",
|
||||
}
|
||||
LogEntry_StreamType_value = map[string]int32{
|
||||
"UNKNOWN": 0,
|
||||
"STDOUT": 1,
|
||||
"STDERR": 2,
|
||||
"HEARTBEAT": 3,
|
||||
}
|
||||
)
|
||||
|
||||
func (x LogEntry_StreamType) Enum() *LogEntry_StreamType {
|
||||
p := new(LogEntry_StreamType)
|
||||
*p = x
|
||||
return p
|
||||
}
|
||||
|
||||
func (x LogEntry_StreamType) String() string {
|
||||
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||
}
|
||||
|
||||
func (LogEntry_StreamType) Descriptor() protoreflect.EnumDescriptor {
|
||||
return file_internal_machine_api_pb_common_proto_enumTypes[0].Descriptor()
|
||||
}
|
||||
|
||||
func (LogEntry_StreamType) Type() protoreflect.EnumType {
|
||||
return &file_internal_machine_api_pb_common_proto_enumTypes[0]
|
||||
}
|
||||
|
||||
func (x LogEntry_StreamType) Number() protoreflect.EnumNumber {
|
||||
return protoreflect.EnumNumber(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use LogEntry_StreamType.Descriptor instead.
|
||||
func (LogEntry_StreamType) EnumDescriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{7, 0}
|
||||
}
|
||||
|
||||
// Common metadata message nested in all reply message types, injected by the gRPC proxy to provide information
|
||||
// about the machine that responded to the request.
|
||||
type Metadata struct {
|
||||
@@ -343,6 +396,150 @@ func (x *IPPrefix) GetBits() uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type LogsRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
// Options for logs retrieval.
|
||||
Follow bool `protobuf:"varint,2,opt,name=follow,proto3" json:"follow,omitempty"`
|
||||
Tail int32 `protobuf:"varint,3,opt,name=tail,proto3" json:"tail,omitempty"` // -1 means all
|
||||
Since string `protobuf:"bytes,4,opt,name=since,proto3" json:"since,omitempty"` // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
|
||||
Until string `protobuf:"bytes,5,opt,name=until,proto3" json:"until,omitempty"` // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
|
||||
}
|
||||
|
||||
func (x *LogsRequest) Reset() {
|
||||
*x = LogsRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_common_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *LogsRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*LogsRequest) ProtoMessage() {}
|
||||
|
||||
func (x *LogsRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_common_proto_msgTypes[6]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use LogsRequest.ProtoReflect.Descriptor instead.
|
||||
func (*LogsRequest) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *LogsRequest) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LogsRequest) GetFollow() bool {
|
||||
if x != nil {
|
||||
return x.Follow
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *LogsRequest) GetTail() int32 {
|
||||
if x != nil {
|
||||
return x.Tail
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LogsRequest) GetSince() string {
|
||||
if x != nil {
|
||||
return x.Since
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LogsRequest) GetUntil() string {
|
||||
if x != nil {
|
||||
return x.Until
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type LogEntry struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Stream LogEntry_StreamType `protobuf:"varint,1,opt,name=stream,proto3,enum=api.LogEntry_StreamType" json:"stream,omitempty"`
|
||||
Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
|
||||
// Log line content. Empty for heartbeat entries.
|
||||
Message []byte `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (x *LogEntry) Reset() {
|
||||
*x = LogEntry{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_internal_machine_api_pb_common_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *LogEntry) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*LogEntry) ProtoMessage() {}
|
||||
|
||||
func (x *LogEntry) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_internal_machine_api_pb_common_proto_msgTypes[7]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use LogEntry.ProtoReflect.Descriptor instead.
|
||||
func (*LogEntry) Descriptor() ([]byte, []int) {
|
||||
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *LogEntry) GetStream() LogEntry_StreamType {
|
||||
if x != nil {
|
||||
return x.Stream
|
||||
}
|
||||
return LogEntry_UNKNOWN
|
||||
}
|
||||
|
||||
func (x *LogEntry) GetTimestamp() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.Timestamp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *LogEntry) GetMessage() []byte {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_internal_machine_api_pb_common_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_internal_machine_api_pb_common_proto_rawDesc = []byte{
|
||||
@@ -350,32 +547,55 @@ var file_internal_machine_api_pb_common_proto_rawDesc = []byte{
|
||||
0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x1a, 0x17, 0x67, 0x6f, 0x6f,
|
||||
0x67, 0x6c, 0x65, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x22, 0x66, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
|
||||
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72,
|
||||
0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72,
|
||||
0x12, 0x2a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x12, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x74,
|
||||
0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x32, 0x0a, 0x05,
|
||||
0x45, 0x6d, 0x70, 0x74, 0x79, 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,
|
||||
0x22, 0x37, 0x0a, 0x0d, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x26, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20,
|
||||
0x03, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x52,
|
||||
0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x14, 0x0a, 0x02, 0x49, 0x50, 0x12,
|
||||
0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70, 0x22,
|
||||
0x35, 0x0a, 0x06, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x02,
|
||||
0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d,
|
||||
0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x37, 0x0a, 0x08, 0x49, 0x50, 0x50, 0x72, 0x65, 0x66,
|
||||
0x69, 0x78, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07,
|
||||
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x62,
|
||||
0x69, 0x74, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x62, 0x69, 0x74, 0x73, 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,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x66, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
|
||||
0x61, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65,
|
||||
0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f,
|
||||
0x72, 0x12, 0x2a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||
0x0b, 0x32, 0x12, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53,
|
||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x32, 0x0a,
|
||||
0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 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, 0x22, 0x37, 0x0a, 0x0d, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x26, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01,
|
||||
0x20, 0x03, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
|
||||
0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x14, 0x0a, 0x02, 0x49, 0x50,
|
||||
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70,
|
||||
0x22, 0x35, 0x0a, 0x06, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52,
|
||||
0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x37, 0x0a, 0x08, 0x49, 0x50, 0x50, 0x72, 0x65,
|
||||
0x66, 0x69, 0x78, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04,
|
||||
0x62, 0x69, 0x74, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x62, 0x69, 0x74, 0x73,
|
||||
0x22, 0x75, 0x0a, 0x0b, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12,
|
||||
0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x6c, 0x6f, 0x77, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52,
|
||||
0x06, 0x66, 0x6f, 0x6c, 0x6c, 0x6f, 0x77, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x69, 0x6c, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x73,
|
||||
0x69, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63,
|
||||
0x65, 0x12, 0x14, 0x0a, 0x05, 0x75, 0x6e, 0x74, 0x69, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x05, 0x75, 0x6e, 0x74, 0x69, 0x6c, 0x22, 0xd2, 0x01, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x45,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x12, 0x30, 0x0a, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x52, 0x06,
|
||||
0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74,
|
||||
0x61, 0x6d, 0x70, 0x18, 0x02, 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, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,
|
||||
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||
0x0c, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x40, 0x0a, 0x0a, 0x53, 0x74,
|
||||
0x72, 0x65, 0x61, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e,
|
||||
0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x44, 0x4f, 0x55, 0x54, 0x10,
|
||||
0x01, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x44, 0x45, 0x52, 0x52, 0x10, 0x02, 0x12, 0x0d, 0x0a,
|
||||
0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x03, 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 (
|
||||
@@ -390,27 +610,34 @@ func file_internal_machine_api_pb_common_proto_rawDescGZIP() []byte {
|
||||
return file_internal_machine_api_pb_common_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_internal_machine_api_pb_common_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||
var file_internal_machine_api_pb_common_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_internal_machine_api_pb_common_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
|
||||
var file_internal_machine_api_pb_common_proto_goTypes = []any{
|
||||
(*Metadata)(nil), // 0: api.Metadata
|
||||
(*Empty)(nil), // 1: api.Empty
|
||||
(*EmptyResponse)(nil), // 2: api.EmptyResponse
|
||||
(*IP)(nil), // 3: api.IP
|
||||
(*IPPort)(nil), // 4: api.IPPort
|
||||
(*IPPrefix)(nil), // 5: api.IPPrefix
|
||||
(*status.Status)(nil), // 6: google.rpc.Status
|
||||
(LogEntry_StreamType)(0), // 0: api.LogEntry.StreamType
|
||||
(*Metadata)(nil), // 1: api.Metadata
|
||||
(*Empty)(nil), // 2: api.Empty
|
||||
(*EmptyResponse)(nil), // 3: api.EmptyResponse
|
||||
(*IP)(nil), // 4: api.IP
|
||||
(*IPPort)(nil), // 5: api.IPPort
|
||||
(*IPPrefix)(nil), // 6: api.IPPrefix
|
||||
(*LogsRequest)(nil), // 7: api.LogsRequest
|
||||
(*LogEntry)(nil), // 8: api.LogEntry
|
||||
(*status.Status)(nil), // 9: google.rpc.Status
|
||||
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
|
||||
}
|
||||
var file_internal_machine_api_pb_common_proto_depIdxs = []int32{
|
||||
6, // 0: api.Metadata.status:type_name -> google.rpc.Status
|
||||
0, // 1: api.Empty.metadata:type_name -> api.Metadata
|
||||
1, // 2: api.EmptyResponse.messages:type_name -> api.Empty
|
||||
3, // 3: api.IPPort.ip:type_name -> api.IP
|
||||
3, // 4: api.IPPrefix.ip:type_name -> api.IP
|
||||
5, // [5:5] is the sub-list for method output_type
|
||||
5, // [5:5] is the sub-list for method input_type
|
||||
5, // [5:5] is the sub-list for extension type_name
|
||||
5, // [5:5] is the sub-list for extension extendee
|
||||
0, // [0:5] is the sub-list for field type_name
|
||||
9, // 0: api.Metadata.status:type_name -> google.rpc.Status
|
||||
1, // 1: api.Empty.metadata:type_name -> api.Metadata
|
||||
2, // 2: api.EmptyResponse.messages:type_name -> api.Empty
|
||||
4, // 3: api.IPPort.ip:type_name -> api.IP
|
||||
4, // 4: api.IPPrefix.ip:type_name -> api.IP
|
||||
0, // 5: api.LogEntry.stream:type_name -> api.LogEntry.StreamType
|
||||
10, // 6: api.LogEntry.timestamp:type_name -> google.protobuf.Timestamp
|
||||
7, // [7:7] is the sub-list for method output_type
|
||||
7, // [7:7] is the sub-list for method input_type
|
||||
7, // [7:7] is the sub-list for extension type_name
|
||||
7, // [7:7] is the sub-list for extension extendee
|
||||
0, // [0:7] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_internal_machine_api_pb_common_proto_init() }
|
||||
@@ -491,19 +718,44 @@ func file_internal_machine_api_pb_common_proto_init() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_common_proto_msgTypes[6].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*LogsRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_internal_machine_api_pb_common_proto_msgTypes[7].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*LogEntry); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_internal_machine_api_pb_common_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 6,
|
||||
NumEnums: 1,
|
||||
NumMessages: 8,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_internal_machine_api_pb_common_proto_goTypes,
|
||||
DependencyIndexes: file_internal_machine_api_pb_common_proto_depIdxs,
|
||||
EnumInfos: file_internal_machine_api_pb_common_proto_enumTypes,
|
||||
MessageInfos: file_internal_machine_api_pb_common_proto_msgTypes,
|
||||
}.Build()
|
||||
File_internal_machine_api_pb_common_proto = out.File
|
||||
|
||||
@@ -6,6 +6,7 @@ option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
||||
|
||||
// Vendored at internal/machine/api/vendor/google/rpc/status.proto.
|
||||
import "google/rpc/status.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
// Common metadata message nested in all reply message types, injected by the gRPC proxy to provide information
|
||||
// about the machine that responded to the request.
|
||||
@@ -42,3 +43,25 @@ message IPPrefix {
|
||||
IP ip = 1;
|
||||
uint32 bits = 2;
|
||||
}
|
||||
|
||||
message LogsRequest {
|
||||
string id = 1;
|
||||
// Options for logs retrieval.
|
||||
bool follow = 2;
|
||||
int32 tail = 3; // -1 means all
|
||||
string since = 4; // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
|
||||
string until = 5; // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
|
||||
}
|
||||
|
||||
message LogEntry {
|
||||
enum StreamType {
|
||||
UNKNOWN = 0;
|
||||
STDOUT = 1;
|
||||
STDERR = 2;
|
||||
HEARTBEAT = 3;
|
||||
}
|
||||
StreamType stream = 1;
|
||||
google.protobuf.Timestamp timestamp = 2;
|
||||
// Log line content. Empty for heartbeat entries.
|
||||
bytes message = 3;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ package api;
|
||||
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "internal/machine/api/pb/common.proto";
|
||||
|
||||
service Docker {
|
||||
@@ -17,7 +16,7 @@ service Docker {
|
||||
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||
|
||||
rpc ExecContainer(stream ExecContainerRequest) returns (stream ExecContainerResponse);
|
||||
rpc ContainerLogs(ContainerLogsRequest) returns (stream ContainerLogEntry);
|
||||
rpc ContainerLogs(LogsRequest) returns (stream LogEntry);
|
||||
|
||||
rpc PullImage(PullImageRequest) returns (stream JSONMessage);
|
||||
rpc InspectImage(InspectImageRequest) returns (InspectImageResponse);
|
||||
@@ -132,28 +131,6 @@ message ExecContainerResponse {
|
||||
}
|
||||
}
|
||||
|
||||
message ContainerLogsRequest {
|
||||
string container_id = 1;
|
||||
// Options for logs retrieval.
|
||||
bool follow = 2;
|
||||
int32 tail = 3; // -1 means all
|
||||
string since = 4; // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
|
||||
string until = 5; // https://www.rfc-editor.org/rfc/rfc3339.html timestamp or Go duration string
|
||||
}
|
||||
|
||||
message ContainerLogEntry {
|
||||
enum StreamType {
|
||||
UNKNOWN = 0;
|
||||
STDOUT = 1;
|
||||
STDERR = 2;
|
||||
HEARTBEAT = 3;
|
||||
}
|
||||
StreamType stream = 1;
|
||||
google.protobuf.Timestamp timestamp = 2;
|
||||
// Log line content. Empty for heartbeat entries.
|
||||
bytes message = 3;
|
||||
}
|
||||
|
||||
message PullImageRequest {
|
||||
string image = 1;
|
||||
// JSON serialised image.PullOptions.
|
||||
@@ -251,6 +228,15 @@ message CreateServiceContainerRequest {
|
||||
// JSON serialised api.ServiceSpec.
|
||||
bytes service_spec = 2;
|
||||
string container_name = 3;
|
||||
|
||||
// ContainerType specifies the purpose of the container being created.
|
||||
enum ContainerType {
|
||||
// SERVICE is the default type for long-running service containers.
|
||||
SERVICE = 0;
|
||||
// PRE_DEPLOY is a one-shot container for a pre-deploy hook to run before service deployment.
|
||||
PRE_DEPLOY = 1;
|
||||
}
|
||||
ContainerType container_type = 4;
|
||||
}
|
||||
|
||||
message ServiceContainer {
|
||||
@@ -275,4 +261,6 @@ message ListServiceContainersResponse {
|
||||
message MachineServiceContainers {
|
||||
Metadata metadata = 1;
|
||||
repeated ServiceContainer containers = 2;
|
||||
// One-shot containers for deployment hooks (e.g. pre-deploy).
|
||||
repeated ServiceContainer hook_containers = 3;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ type DockerClient interface {
|
||||
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error)
|
||||
RemoveContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error)
|
||||
ContainerLogs(ctx context.Context, in *ContainerLogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ContainerLogEntry], error)
|
||||
ContainerLogs(ctx context.Context, in *LogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[LogEntry], error)
|
||||
PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error)
|
||||
InspectImage(ctx context.Context, in *InspectImageRequest, opts ...grpc.CallOption) (*InspectImageResponse, error)
|
||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||
@@ -149,13 +149,13 @@ func (c *dockerClient) ExecContainer(ctx context.Context, opts ...grpc.CallOptio
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Docker_ExecContainerClient = grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse]
|
||||
|
||||
func (c *dockerClient) ContainerLogs(ctx context.Context, in *ContainerLogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ContainerLogEntry], error) {
|
||||
func (c *dockerClient) ContainerLogs(ctx context.Context, in *LogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[LogEntry], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[1], Docker_ContainerLogs_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[ContainerLogsRequest, ContainerLogEntry]{ClientStream: stream}
|
||||
x := &grpc.GenericClientStream[LogsRequest, LogEntry]{ClientStream: stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func (c *dockerClient) ContainerLogs(ctx context.Context, in *ContainerLogsReque
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Docker_ContainerLogsClient = grpc.ServerStreamingClient[ContainerLogEntry]
|
||||
type Docker_ContainerLogsClient = grpc.ServerStreamingClient[LogEntry]
|
||||
|
||||
func (c *dockerClient) PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
@@ -298,7 +298,7 @@ type DockerServer interface {
|
||||
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
|
||||
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
||||
ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error
|
||||
ContainerLogs(*ContainerLogsRequest, grpc.ServerStreamingServer[ContainerLogEntry]) error
|
||||
ContainerLogs(*LogsRequest, grpc.ServerStreamingServer[LogEntry]) error
|
||||
PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error
|
||||
InspectImage(context.Context, *InspectImageRequest) (*InspectImageResponse, error)
|
||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||
@@ -343,7 +343,7 @@ func (UnimplementedDockerServer) RemoveContainer(context.Context, *RemoveContain
|
||||
func (UnimplementedDockerServer) ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method ExecContainer not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) ContainerLogs(*ContainerLogsRequest, grpc.ServerStreamingServer[ContainerLogEntry]) error {
|
||||
func (UnimplementedDockerServer) ContainerLogs(*LogsRequest, grpc.ServerStreamingServer[LogEntry]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method ContainerLogs not implemented")
|
||||
}
|
||||
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
|
||||
@@ -516,15 +516,15 @@ func _Docker_ExecContainer_Handler(srv interface{}, stream grpc.ServerStream) er
|
||||
type Docker_ExecContainerServer = grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]
|
||||
|
||||
func _Docker_ContainerLogs_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(ContainerLogsRequest)
|
||||
m := new(LogsRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(DockerServer).ContainerLogs(m, &grpc.GenericServerStream[ContainerLogsRequest, ContainerLogEntry]{ServerStream: stream})
|
||||
return srv.(DockerServer).ContainerLogs(m, &grpc.GenericServerStream[LogsRequest, LogEntry]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Docker_ContainerLogsServer = grpc.ServerStreamingServer[ContainerLogEntry]
|
||||
type Docker_ContainerLogsServer = grpc.ServerStreamingServer[LogEntry]
|
||||
|
||||
func _Docker_PullImage_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(PullImageRequest)
|
||||
|
||||
@@ -1146,7 +1146,7 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
|
||||
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,
|
||||
0x73, 0x32, 0x95, 0x05, 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,
|
||||
@@ -1184,11 +1184,14 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
|
||||
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,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x0b, 0x4d, 0x61, 0x63, 0x68, 0x69,
|
||||
0x6e, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67,
|
||||
0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c,
|
||||
0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x30, 0x01, 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 (
|
||||
@@ -1227,6 +1230,8 @@ var file_internal_machine_api_pb_machine_proto_goTypes = []any{
|
||||
(*Metadata)(nil), // 19: api.Metadata
|
||||
(*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp
|
||||
(*emptypb.Empty)(nil), // 21: google.protobuf.Empty
|
||||
(*LogsRequest)(nil), // 22: api.LogsRequest
|
||||
(*LogEntry)(nil), // 23: api.LogEntry
|
||||
}
|
||||
var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
|
||||
1, // 0: api.MachineInfo.network:type_name -> api.NetworkConfig
|
||||
@@ -1256,17 +1261,19 @@ var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
|
||||
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
|
||||
22, // 27: api.Machine.MachineLogs:input_type -> api.LogsRequest
|
||||
2, // 28: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
|
||||
4, // 29: api.Machine.InitCluster:output_type -> api.InitClusterResponse
|
||||
21, // 30: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
|
||||
8, // 31: api.Machine.Token:output_type -> api.TokenResponse
|
||||
0, // 32: api.Machine.Inspect:output_type -> api.MachineInfo
|
||||
6, // 33: api.Machine.InspectMachine:output_type -> api.InspectMachineResponse
|
||||
13, // 34: api.Machine.InspectWireGuardNetwork:output_type -> api.InspectWireGuardNetworkResponse
|
||||
21, // 35: api.Machine.Reset:output_type -> google.protobuf.Empty
|
||||
12, // 36: api.Machine.InspectService:output_type -> api.InspectServiceResponse
|
||||
23, // 37: api.Machine.MachineLogs:output_type -> api.LogEntry
|
||||
28, // [28:38] is the sub-list for method output_type
|
||||
18, // [18:28] 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
|
||||
|
||||
@@ -24,6 +24,8 @@ service Machine {
|
||||
rpc Reset(ResetRequest) returns (google.protobuf.Empty);
|
||||
|
||||
rpc InspectService(InspectServiceRequest) returns (InspectServiceResponse);
|
||||
|
||||
rpc MachineLogs(LogsRequest) returns (stream LogEntry);
|
||||
}
|
||||
|
||||
message MachineInfo {
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
Machine_InspectWireGuardNetwork_FullMethodName = "/api.Machine/InspectWireGuardNetwork"
|
||||
Machine_Reset_FullMethodName = "/api.Machine/Reset"
|
||||
Machine_InspectService_FullMethodName = "/api.Machine/InspectService"
|
||||
Machine_MachineLogs_FullMethodName = "/api.Machine/MachineLogs"
|
||||
)
|
||||
|
||||
// MachineClient is the client API for Machine service.
|
||||
@@ -49,6 +50,7 @@ type MachineClient interface {
|
||||
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
|
||||
Reset(ctx context.Context, in *ResetRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
InspectService(ctx context.Context, in *InspectServiceRequest, opts ...grpc.CallOption) (*InspectServiceResponse, error)
|
||||
MachineLogs(ctx context.Context, in *LogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[LogEntry], error)
|
||||
}
|
||||
|
||||
type machineClient struct {
|
||||
@@ -149,6 +151,25 @@ func (c *machineClient) InspectService(ctx context.Context, in *InspectServiceRe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *machineClient) MachineLogs(ctx context.Context, in *LogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[LogEntry], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &Machine_ServiceDesc.Streams[0], Machine_MachineLogs_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[LogsRequest, LogEntry]{ClientStream: stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := x.ClientStream.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Machine_MachineLogsClient = grpc.ServerStreamingClient[LogEntry]
|
||||
|
||||
// MachineServer is the server API for Machine service.
|
||||
// All implementations must embed UnimplementedMachineServer
|
||||
// for forward compatibility.
|
||||
@@ -167,6 +188,7 @@ type MachineServer interface {
|
||||
// Reset restores the machine to a clean state, removing all cluster-related configuration and data.
|
||||
Reset(context.Context, *ResetRequest) (*emptypb.Empty, error)
|
||||
InspectService(context.Context, *InspectServiceRequest) (*InspectServiceResponse, error)
|
||||
MachineLogs(*LogsRequest, grpc.ServerStreamingServer[LogEntry]) error
|
||||
mustEmbedUnimplementedMachineServer()
|
||||
}
|
||||
|
||||
@@ -204,6 +226,9 @@ func (UnimplementedMachineServer) Reset(context.Context, *ResetRequest) (*emptyp
|
||||
func (UnimplementedMachineServer) InspectService(context.Context, *InspectServiceRequest) (*InspectServiceResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method InspectService not implemented")
|
||||
}
|
||||
func (UnimplementedMachineServer) MachineLogs(*LogsRequest, grpc.ServerStreamingServer[LogEntry]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method MachineLogs not implemented")
|
||||
}
|
||||
func (UnimplementedMachineServer) mustEmbedUnimplementedMachineServer() {}
|
||||
func (UnimplementedMachineServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -387,6 +412,17 @@ func _Machine_InspectService_Handler(srv interface{}, ctx context.Context, dec f
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Machine_MachineLogs_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(LogsRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(MachineServer).MachineLogs(m, &grpc.GenericServerStream[LogsRequest, LogEntry]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Machine_MachineLogsServer = grpc.ServerStreamingServer[LogEntry]
|
||||
|
||||
// Machine_ServiceDesc is the grpc.ServiceDesc for Machine service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -431,6 +467,12 @@ var Machine_ServiceDesc = grpc.ServiceDesc{
|
||||
Handler: _Machine_InspectService_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "MachineLogs",
|
||||
Handler: _Machine_MachineLogs_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "internal/machine/api/pb/machine.proto",
|
||||
}
|
||||
|
||||
@@ -94,13 +94,16 @@ func (c *Controller) Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// filterHealthyContainers filters out containers that are not healthy.
|
||||
// filterHealthyContainers filters out unhealthy and hook containers.
|
||||
// TODO: Filters out containers from this machine that are likely unavailable. The availability can be determined
|
||||
// by the cluster membership state of the machine that the container is running on. Implement machine membership
|
||||
// check using Corrossion Admin client.
|
||||
func filterHealthyContainers(containers []store.ContainerRecord) []store.ContainerRecord {
|
||||
healthy := make([]store.ContainerRecord, 0, len(containers))
|
||||
for _, cr := range containers {
|
||||
if cr.Container.IsHook() {
|
||||
continue
|
||||
}
|
||||
if cr.Container.Healthy() {
|
||||
healthy = append(healthy, cr)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
||||
|
||||
containersCount := 0
|
||||
for _, record := range containers {
|
||||
if record.Container.IsHook() {
|
||||
continue
|
||||
}
|
||||
if !record.Container.Healthy() {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -420,34 +420,6 @@ func (c *Client) RemoveVolume(ctx context.Context, id string, force bool) error
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateServiceContainer creates a new container for the service with the given specifications.
|
||||
func (c *Client) CreateServiceContainer(
|
||||
ctx context.Context, serviceID string, spec api.ServiceSpec, containerName string,
|
||||
) (container.CreateResponse, error) {
|
||||
var resp container.CreateResponse
|
||||
|
||||
specBytes, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("marshal service spec: %w", err)
|
||||
}
|
||||
grpcResp, err := c.GRPCClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
|
||||
ServiceId: serviceID,
|
||||
ServiceSpec: specBytes,
|
||||
ContainerName: containerName,
|
||||
})
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return resp, errdefs.NotFound(err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(grpcResp.Response, &resp); err != nil {
|
||||
return resp, fmt.Errorf("unmarshal gRPC response: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// InspectServiceContainer returns the container information and service specification that was used to create the
|
||||
// container with the given ID.
|
||||
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
|
||||
@@ -474,10 +446,13 @@ func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.Se
|
||||
type MachineServiceContainers struct {
|
||||
Metadata *pb.Metadata
|
||||
Containers []api.ServiceContainer
|
||||
// HookContainers are one-shot containers for deployment hooks (e.g. pre-deploy).
|
||||
HookContainers []api.ServiceContainer
|
||||
}
|
||||
|
||||
// ListServiceContainers returns all containers on requested machines that belong to the service with the given
|
||||
// name or ID. If serviceNameOrID is empty, all service containers are returned.
|
||||
// Set opts.All to true to include hook containers.
|
||||
func (c *Client) ListServiceContainers(
|
||||
ctx context.Context, serviceNameOrID string, opts container.ListOptions,
|
||||
) ([]MachineServiceContainers, error) {
|
||||
@@ -501,22 +476,33 @@ func (c *Client) ListServiceContainers(
|
||||
continue
|
||||
}
|
||||
|
||||
containers := make([]api.ServiceContainer, len(msg.Containers))
|
||||
for j, sc := range msg.Containers {
|
||||
if err = json.Unmarshal(sc.Container, &containers[j].Container); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal container: %w", err)
|
||||
}
|
||||
if err = json.Unmarshal(sc.ServiceSpec, &containers[j].ServiceSpec); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal service spec: %w", err)
|
||||
}
|
||||
machineContainers[i].Containers, err = serviceContainersFromProto(msg.Containers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
machineContainers[i].HookContainers, err = serviceContainersFromProto(msg.HookContainers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
machineContainers[i].Containers = containers
|
||||
}
|
||||
|
||||
return machineContainers, nil
|
||||
}
|
||||
|
||||
// serviceContainersFromProto converts a slice of protobuf service containers to api.ServiceContainer.
|
||||
func serviceContainersFromProto(pbContainers []*pb.ServiceContainer) ([]api.ServiceContainer, error) {
|
||||
containers := make([]api.ServiceContainer, len(pbContainers))
|
||||
for i, sc := range pbContainers {
|
||||
if err := json.Unmarshal(sc.Container, &containers[i].Container); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal container: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(sc.ServiceSpec, &containers[i].ServiceSpec); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal service spec: %w", err)
|
||||
}
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
// RemoveServiceContainer stops (kills after grace period) and removes a service container with the given ID.
|
||||
// A service container is a container that has been created with CreateServiceContainer.
|
||||
func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts container.RemoveOptions) error {
|
||||
|
||||
@@ -152,12 +152,16 @@ func (c *Controller) syncContainersToStore(ctx context.Context) error {
|
||||
return fmt.Errorf("list containers from store: %w", err)
|
||||
}
|
||||
|
||||
containers, err := c.service.ListServiceContainers(ctx, "", container.ListOptions{})
|
||||
// List containers for all services, including stopped ones and deployment hooks.
|
||||
result, err := c.service.ListServiceContainers(ctx, "", container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
// TODO: mark all containers as outdated in the store.
|
||||
return fmt.Errorf("list service containers: %w", err)
|
||||
}
|
||||
|
||||
// Sync both regular and one-off hook containers to the store.
|
||||
containers := append(result.Containers, result.HookContainers...)
|
||||
|
||||
// Delete containers from the store that are no longer present in the Docker daemon.
|
||||
var deleteIDs []string
|
||||
for _, sc := range storeContainers {
|
||||
|
||||
@@ -664,6 +664,39 @@ func (s *Server) CreateServiceContainer(
|
||||
}
|
||||
}
|
||||
|
||||
// Strip the container configuration that doesn't make sense for the pre-deploy hook and apply its overrides.
|
||||
if spec.PreDeploy != nil && req.ContainerType == pb.CreateServiceContainerRequest_PRE_DEPLOY {
|
||||
config.Labels = map[string]string{
|
||||
api.LabelServiceID: req.ServiceId,
|
||||
api.LabelServiceName: spec.Name,
|
||||
api.LabelHook: api.LabelHookPreDeploy,
|
||||
api.LabelManaged: "",
|
||||
}
|
||||
config.Healthcheck = &container.HealthConfig{
|
||||
Test: []string{"NONE"},
|
||||
}
|
||||
hostConfig.PortBindings = nil
|
||||
hostConfig.RestartPolicy = container.RestartPolicy{
|
||||
Name: container.RestartPolicyDisabled,
|
||||
}
|
||||
|
||||
// Apply the pre-deploy hook overrides.
|
||||
config.Cmd = spec.PreDeploy.Command
|
||||
|
||||
for k, v := range spec.PreDeploy.Env {
|
||||
envVars[k] = v
|
||||
}
|
||||
envVars["UNCLOUD_HOOK_PRE_DEPLOY"] = "true"
|
||||
config.Env = envVars.ToSlice()
|
||||
|
||||
if spec.PreDeploy.Privileged != nil {
|
||||
hostConfig.Privileged = *spec.PreDeploy.Privileged
|
||||
}
|
||||
if spec.PreDeploy.User != "" {
|
||||
config.User = spec.PreDeploy.User
|
||||
}
|
||||
}
|
||||
|
||||
networkConfig := &network.NetworkingConfig{
|
||||
EndpointsConfig: map[string]*network.EndpointSettings{
|
||||
NetworkName: {},
|
||||
@@ -1009,37 +1042,49 @@ func (s *Server) ListServiceContainers(
|
||||
}
|
||||
}
|
||||
|
||||
containers, err := s.service.ListServiceContainers(ctx, req.ServiceId, opts)
|
||||
result, err := s.service.ListServiceContainers(ctx, req.ServiceId, opts)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
// Convert to protobuf format.
|
||||
pbContainers, err := serviceContainersToProto(result.Containers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pbHookContainers, err := serviceContainersToProto(result.HookContainers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pb.ListServiceContainersResponse{
|
||||
Messages: []*pb.MachineServiceContainers{
|
||||
{
|
||||
Containers: pbContainers,
|
||||
HookContainers: pbHookContainers,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// serviceContainersToProto converts a slice of service containers to protobuf format.
|
||||
func serviceContainersToProto(containers []api.ServiceContainer) ([]*pb.ServiceContainer, error) {
|
||||
pbContainers := make([]*pb.ServiceContainer, 0, len(containers))
|
||||
for _, ctr := range containers {
|
||||
ctrBytes, err := json.Marshal(ctr.Container)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "marshal container: %v", err)
|
||||
}
|
||||
|
||||
specBytes, err := json.Marshal(ctr.ServiceSpec)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
|
||||
}
|
||||
|
||||
pbContainers = append(pbContainers, &pb.ServiceContainer{
|
||||
Container: ctrBytes,
|
||||
ServiceSpec: specBytes,
|
||||
})
|
||||
}
|
||||
|
||||
return &pb.ListServiceContainersResponse{
|
||||
Messages: []*pb.MachineServiceContainers{
|
||||
{
|
||||
Containers: pbContainers,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
return pbContainers, nil
|
||||
}
|
||||
|
||||
// RemoveServiceContainer stops (kills after grace period) and removes a service container with the given ID.
|
||||
@@ -1078,20 +1123,19 @@ const logsHeartbeatInterval = 200 * time.Millisecond
|
||||
|
||||
// ContainerLogs streams logs from a container.
|
||||
func (s *Server) ContainerLogs(
|
||||
req *pb.ContainerLogsRequest, stream grpc.ServerStreamingServer[pb.ContainerLogEntry],
|
||||
req *pb.LogsRequest, stream grpc.ServerStreamingServer[pb.LogEntry],
|
||||
) error {
|
||||
// Stream context is cancelled when the client has disconnected or the stream has ended.
|
||||
ctx := stream.Context()
|
||||
|
||||
opts := ContainerLogsOptions{
|
||||
ContainerID: req.ContainerId,
|
||||
Follow: req.Follow,
|
||||
Tail: int(req.Tail),
|
||||
Since: req.Since,
|
||||
Until: req.Until,
|
||||
opts := api.ServiceLogsOptions{
|
||||
Follow: req.Follow,
|
||||
Tail: int(req.Tail),
|
||||
Since: req.Since,
|
||||
Until: req.Until,
|
||||
}
|
||||
|
||||
logsCh, err := s.service.ContainerLogs(ctx, opts)
|
||||
logsCh, err := s.service.ContainerLogs(ctx, req.Id, opts)
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
@@ -1099,7 +1143,7 @@ func (s *Server) ContainerLogs(
|
||||
return status.Errorf(codes.Internal, "get container logs: %v", err)
|
||||
}
|
||||
|
||||
log := slog.With("container_id", req.ContainerId, "stream_id", fmt.Sprintf("%p", stream)[2:])
|
||||
log := slog.With("container_id", req.Id, "stream_id", fmt.Sprintf("%p", stream)[2:])
|
||||
log.Debug("Starting container logs streaming.",
|
||||
"follow", req.Follow, "tail", req.Tail, "since", req.Since, "until", req.Until)
|
||||
|
||||
@@ -1127,7 +1171,7 @@ func (s *Server) ContainerLogs(
|
||||
return status.Error(codes.Internal, entry.Err.Error())
|
||||
}
|
||||
|
||||
pbEntry := &pb.ContainerLogEntry{
|
||||
pbEntry := &pb.LogEntry{
|
||||
Stream: api.LogStreamTypeToProto(entry.Stream),
|
||||
Timestamp: timestamppb.New(entry.Timestamp),
|
||||
Message: entry.Message,
|
||||
@@ -1148,8 +1192,8 @@ func (s *Server) ContainerLogs(
|
||||
// Use the timestamp one heartbeat in the past to be conservative. This reduces the chance of sending
|
||||
// a timestamp that is greater than a log entry currently being parsed but not yet sent, which would
|
||||
// cause the client to incorrectly believe it has received all logs up to that point.
|
||||
heartbeat := &pb.ContainerLogEntry{
|
||||
Stream: pb.ContainerLogEntry_HEARTBEAT,
|
||||
heartbeat := &pb.LogEntry{
|
||||
Stream: pb.LogEntry_HEARTBEAT,
|
||||
Timestamp: timestamppb.New(now.Add(-logsHeartbeatInterval)),
|
||||
}
|
||||
if err = stream.Send(heartbeat); err != nil {
|
||||
|
||||
@@ -74,11 +74,20 @@ func (s *Service) InspectServiceContainer(ctx context.Context, nameOrID string)
|
||||
return serviceCtr, nil
|
||||
}
|
||||
|
||||
// ListServiceContainersResult holds the result of listing service containers, split into regular
|
||||
// service containers and one-off hook containers.
|
||||
type ListServiceContainersResult struct {
|
||||
Containers []api.ServiceContainer
|
||||
HookContainers []api.ServiceContainer
|
||||
}
|
||||
|
||||
// ListServiceContainers lists Docker containers that belong to the service with the given name or ID.
|
||||
// If serviceIDOrName is empty, all service containers are returned. The opts parameter allows additional filtering.
|
||||
func (s *Service) ListServiceContainers(
|
||||
ctx context.Context, serviceNameOrID string, opts container.ListOptions,
|
||||
) ([]api.ServiceContainer, error) {
|
||||
) (ListServiceContainersResult, error) {
|
||||
var result ListServiceContainersResult
|
||||
|
||||
if opts.Filters.Len() == 0 {
|
||||
opts.Filters = filters.NewArgs()
|
||||
}
|
||||
@@ -88,10 +97,9 @@ func (s *Service) ListServiceContainers(
|
||||
|
||||
containerSummaries, err := s.Client.ContainerList(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return result, err
|
||||
}
|
||||
|
||||
var containers []api.ServiceContainer
|
||||
for _, cs := range containerSummaries {
|
||||
// Filter by service name or ID if provided.
|
||||
if serviceNameOrID != "" &&
|
||||
@@ -106,10 +114,15 @@ func (s *Service) ListServiceContainers(
|
||||
slog.Error("Failed to inspect service container.", "service", serviceNameOrID, "id", cs.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
containers = append(containers, ctr)
|
||||
|
||||
if ctr.IsHook() {
|
||||
result.HookContainers = append(result.HookContainers, ctr)
|
||||
} else {
|
||||
result.Containers = append(result.Containers, ctr)
|
||||
}
|
||||
}
|
||||
|
||||
return containers, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsContainerdImageStoreEnabled checks if Docker is configured to use the containerd image store:
|
||||
@@ -155,18 +168,9 @@ func (s *Service) ListImages(ctx context.Context, opts image.ListOptions) (Image
|
||||
return imagesResp, nil
|
||||
}
|
||||
|
||||
// ContainerLogsOptions specifies parameters for ContainerLogs.
|
||||
type ContainerLogsOptions struct {
|
||||
ContainerID string
|
||||
Follow bool
|
||||
Tail int
|
||||
Since string
|
||||
Until string
|
||||
}
|
||||
|
||||
// ContainerLogs streams logs from a container and returns demultiplexed entries via a channel.
|
||||
// The channel is closed when streaming completes or context is cancelled.
|
||||
func (s *Service) ContainerLogs(ctx context.Context, opts ContainerLogsOptions) (<-chan api.ContainerLogEntry, error) {
|
||||
func (s *Service) ContainerLogs(ctx context.Context, containerID string, opts api.ServiceLogsOptions) (<-chan api.LogEntry, error) {
|
||||
dockerOpts := container.LogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
@@ -177,12 +181,12 @@ func (s *Service) ContainerLogs(ctx context.Context, opts ContainerLogsOptions)
|
||||
Timestamps: true,
|
||||
}
|
||||
|
||||
reader, err := s.Client.ContainerLogs(ctx, opts.ContainerID, dockerOpts)
|
||||
reader, err := s.Client.ContainerLogs(ctx, containerID, dockerOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outCh := make(chan api.ContainerLogEntry)
|
||||
outCh := make(chan api.LogEntry)
|
||||
stdoutWriter := &logsChannelWriter{ctx: ctx, ch: outCh, isStderr: false}
|
||||
stderrWriter := &logsChannelWriter{ctx: ctx, ch: outCh, isStderr: true}
|
||||
|
||||
@@ -198,7 +202,7 @@ func (s *Service) ContainerLogs(ctx context.Context, opts ContainerLogsOptions)
|
||||
if _, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, reader); err != nil {
|
||||
// Send error as the last entry.
|
||||
select {
|
||||
case outCh <- api.ContainerLogEntry{Err: fmt.Errorf("demultiplex container logs: %w", err)}:
|
||||
case outCh <- api.LogEntry{Err: fmt.Errorf("demultiplex container logs: %w", err)}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
@@ -216,7 +220,7 @@ func (s *Service) ContainerLogs(ctx context.Context, opts ContainerLogsOptions)
|
||||
// logsChannelWriter is a writer for stdcopy.StdCopy that sends demultiplexed container logs to a channel.
|
||||
type logsChannelWriter struct {
|
||||
ctx context.Context
|
||||
ch chan<- api.ContainerLogEntry
|
||||
ch chan<- api.LogEntry
|
||||
isStderr bool
|
||||
}
|
||||
|
||||
@@ -236,7 +240,7 @@ func (w *logsChannelWriter) Write(data []byte) (n int, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
entry := api.ContainerLogEntry{
|
||||
entry := api.LogEntry{
|
||||
Timestamp: timestamp,
|
||||
// Clone is required because message is a slice into data, which stdcopy.StdCopy may reuse
|
||||
// after Write returns but before the entry is consumed from the channel.
|
||||
|
||||
@@ -14,12 +14,16 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/sockets"
|
||||
"github.com/psviderski/uncloud/internal/corrosion"
|
||||
"github.com/psviderski/uncloud/internal/docker"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/grpcversion"
|
||||
"github.com/psviderski/uncloud/internal/journal"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
apiproxy "github.com/psviderski/uncloud/internal/machine/api/proxy"
|
||||
"github.com/psviderski/uncloud/internal/machine/caddyconfig"
|
||||
@@ -30,6 +34,7 @@ import (
|
||||
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/unregistry"
|
||||
"github.com/siderolabs/grpc-proxy/proxy"
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -268,6 +273,8 @@ func NewMachine(config *Config) (*Machine, error) {
|
||||
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, constants.MachineAPIPort)
|
||||
localProxyServer := grpc.NewServer(
|
||||
grpc.ForceServerCodecV2(proxy.Codec()),
|
||||
grpc.UnaryInterceptor(grpcversion.ServerUnaryInterceptor),
|
||||
grpc.StreamInterceptor(grpcversion.ServerStreamInterceptor),
|
||||
grpc.UnknownServiceHandler(
|
||||
proxy.TransparentHandler(proxyDirector.Director),
|
||||
),
|
||||
@@ -421,6 +428,8 @@ func (m *Machine) Run(ctx context.Context) error {
|
||||
m.proxyDirector.UpdateLocalAddress(m.state.Network.ManagementIP.String())
|
||||
proxyServer := grpc.NewServer(
|
||||
grpc.ForceServerCodecV2(proxy.Codec()),
|
||||
grpc.UnaryInterceptor(grpcversion.ServerUnaryInterceptor),
|
||||
grpc.StreamInterceptor(grpcversion.ServerStreamInterceptor),
|
||||
grpc.UnknownServiceHandler(
|
||||
proxy.TransparentHandler(m.proxyDirector.Director),
|
||||
),
|
||||
@@ -1073,3 +1082,93 @@ func (m *Machine) InspectService(
|
||||
}
|
||||
return &pb.InspectServiceResponse{Service: svc}, nil
|
||||
}
|
||||
|
||||
// logsHeartbeatInterval is the interval at which heartbeat entries are sent when there are no logs to stream.
|
||||
const logsHeartbeatInterval = 200 * time.Millisecond
|
||||
|
||||
// MachineLogs streams logs from a systemd service.
|
||||
func (s *Machine) MachineLogs(
|
||||
req *pb.LogsRequest, stream grpc.ServerStreamingServer[pb.LogEntry],
|
||||
) error {
|
||||
// TODO(miek): almost duplicate of docker/server.ContainerLogs
|
||||
ctx := stream.Context()
|
||||
|
||||
opts := api.ServiceLogsOptions{
|
||||
Follow: req.Follow,
|
||||
Tail: int(req.Tail),
|
||||
Since: req.Since,
|
||||
Until: req.Until,
|
||||
}
|
||||
|
||||
logsCh, err := journal.Logs(ctx, req.Id, opts)
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return status.Errorf(codes.Internal, "get journal logs: %v", err)
|
||||
}
|
||||
|
||||
log := slog.With("unit", req.Id, "stream_id", fmt.Sprintf("%p", stream)[2:])
|
||||
log.Debug("Starting systemd service logs streaming.",
|
||||
"follow", req.Follow, "tail", req.Tail, "since", req.Since, "until", req.Until)
|
||||
|
||||
// Heartbeats are needed only when following logs to let the client know when there are no new log entries
|
||||
// to allow it to advance the watermark of last received log timestamp.
|
||||
var heartbeatCh <-chan time.Time
|
||||
if req.Follow {
|
||||
heartbeatTicker := time.NewTicker(logsHeartbeatInterval)
|
||||
defer heartbeatTicker.Stop()
|
||||
heartbeatCh = heartbeatTicker.C
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
lastSent := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-logsCh:
|
||||
if !ok {
|
||||
// Channel closed, no more log entries.
|
||||
return nil
|
||||
}
|
||||
|
||||
if entry.Err != nil {
|
||||
return status.Error(codes.Internal, entry.Err.Error())
|
||||
}
|
||||
|
||||
pbEntry := &pb.LogEntry{
|
||||
Stream: api.LogStreamTypeToProto(entry.Stream),
|
||||
Timestamp: timestamppb.New(entry.Timestamp),
|
||||
Message: entry.Message,
|
||||
}
|
||||
if err = stream.Send(pbEntry); err != nil {
|
||||
return status.Errorf(codes.Internal, "send log entry: %v", err)
|
||||
}
|
||||
lastSent = entry.Timestamp
|
||||
|
||||
case now := <-heartbeatCh:
|
||||
// Only send heartbeat if no log entries have been sent since the last heartbeat interval or
|
||||
// if no log entries have been sent at all for at least a heartbeat interval since starting.
|
||||
if now.Sub(lastSent) < logsHeartbeatInterval ||
|
||||
(lastSent.IsZero() && now.Sub(started) < logsHeartbeatInterval) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the timestamp one heartbeat in the past to be conservative. This reduces the chance of sending
|
||||
// a timestamp that is greater than a log entry currently being parsed but not yet sent, which would
|
||||
// cause the client to incorrectly believe it has received all logs up to that point.
|
||||
heartbeat := &pb.LogEntry{
|
||||
Stream: pb.LogEntry_HEARTBEAT,
|
||||
Timestamp: timestamppb.New(now.Add(-logsHeartbeatInterval)),
|
||||
}
|
||||
if err = stream.Send(heartbeat); err != nil {
|
||||
return status.Errorf(codes.Internal, "send log stream heartbeat: %v", err)
|
||||
}
|
||||
lastSent = heartbeat.Timestamp.AsTime()
|
||||
log.Debug("Sent log stream heartbeat.", "timestamp", lastSent)
|
||||
|
||||
case <-ctx.Done():
|
||||
return status.Error(codes.Canceled, ctx.Err().Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error
|
||||
}
|
||||
defer initClient.Close()
|
||||
|
||||
if err := initClient.WaitMachineReady(ctx, 30*time.Second); err != nil {
|
||||
if err := initClient.WaitMachineReady(ctx, 90*time.Second); err != nil {
|
||||
return fmt.Errorf("wait for machine %q to be ready: %w", initMachine.Name, err)
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error
|
||||
|
||||
fmt.Printf("Cluster %q initialised with machine %q\n", initMachine.ClusterName, initResp.Machine.Name)
|
||||
fmt.Printf("Waiting for cluster to be ready...")
|
||||
if err = initClient.WaitClusterReady(ctx, 30*time.Second); err != nil {
|
||||
if err = initClient.WaitClusterReady(ctx, 90*time.Second); err != nil {
|
||||
return fmt.Errorf("wait for cluster to be ready: %w", err)
|
||||
}
|
||||
fmt.Println(" done.")
|
||||
@@ -160,7 +160,7 @@ func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error
|
||||
//goland:noinspection GoDeferInLoop
|
||||
defer cli.Close()
|
||||
|
||||
if err := cli.WaitMachineReady(ctx, 30*time.Second); err != nil {
|
||||
if err := cli.WaitMachineReady(ctx, 90*time.Second); err != nil {
|
||||
return fmt.Errorf("wait for machine %q to be ready: %w", m.Name, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,8 @@ package version
|
||||
var version string
|
||||
|
||||
func String() string {
|
||||
if version == "" {
|
||||
return "999.0.0-dev"
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
+4
-8
@@ -20,7 +20,10 @@ type Client interface {
|
||||
type ContainerClient interface {
|
||||
CreateContainer(
|
||||
ctx context.Context, serviceID string, spec ServiceSpec, machineID string,
|
||||
) (container.CreateResponse, error)
|
||||
) (CreateContainerResponse, error)
|
||||
CreatePreDeployHookContainer(
|
||||
ctx context.Context, serviceID string, spec ServiceSpec, machineID string,
|
||||
) (CreateContainerResponse, error)
|
||||
ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error)
|
||||
InspectContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) (MachineServiceContainer, error)
|
||||
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
|
||||
@@ -60,10 +63,3 @@ 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.
|
||||
//
|
||||
//go:fix inline
|
||||
func AsPtr[T any](v T) *T {
|
||||
return new(v)
|
||||
}
|
||||
|
||||
+20
-1
@@ -18,11 +18,16 @@ const (
|
||||
// DockerNetworkName is the name of the Docker network used by uncloud. Keep the value in sync with NetworkName
|
||||
// in internal/machine/docker/manager.go.
|
||||
DockerNetworkName = "uncloud"
|
||||
|
||||
LabelManaged = "uncloud.managed"
|
||||
LabelServiceID = "uncloud.service.id"
|
||||
LabelServiceName = "uncloud.service.name"
|
||||
LabelServiceMode = "uncloud.service.mode"
|
||||
LabelServicePorts = "uncloud.service.ports"
|
||||
// LabelHook marks a container as a deployment hook. The value indicates the hook type (e.g. LabelHookPreDeploy).
|
||||
LabelHook = "uncloud.service.hook"
|
||||
// LabelHookPreDeploy indicates that the container is a pre-deploy hook that runs before deploying the service.
|
||||
LabelHookPreDeploy = "pre-deploy"
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
@@ -159,6 +164,13 @@ func (c *Container) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateContainerResponse wraps a container creation response with the container name assigned during creation.
|
||||
type CreateContainerResponse struct {
|
||||
container.CreateResponse
|
||||
// Name is the container name assigned during creation.
|
||||
Name string
|
||||
}
|
||||
|
||||
type ServiceContainer struct {
|
||||
Container
|
||||
ServiceSpec ServiceSpec
|
||||
@@ -181,10 +193,17 @@ func (c *ServiceContainer) ServiceName() string {
|
||||
|
||||
// ServiceMode returns the replication mode of the service this container belongs to.
|
||||
func (c *ServiceContainer) ServiceMode() string {
|
||||
return c.Config.Labels[LabelServiceMode]
|
||||
return c.ServiceSpec.Mode
|
||||
}
|
||||
|
||||
// IsHook returns true if the container is a deployment hook (e.g. pre-deploy).
|
||||
func (c *ServiceContainer) IsHook() bool {
|
||||
_, ok := c.Config.Labels[LabelHook]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ServicePorts returns the ports this container publishes as part of its service.
|
||||
// TODO: return ports from ServiceSpec to allow updating ingress ports without recreating containers.
|
||||
func (c *ServiceContainer) ServicePorts() ([]PortSpec, error) {
|
||||
encoded, ok := c.Config.Labels[LabelServicePorts]
|
||||
if !ok {
|
||||
|
||||
+14
-14
@@ -18,31 +18,31 @@ const (
|
||||
|
||||
type LogStreamType int
|
||||
|
||||
// LogStreamTypeFromProto converts a protobuf ContainerLogEntry.StreamType to the internal LogStreamType.
|
||||
func LogStreamTypeFromProto(s pb.ContainerLogEntry_StreamType) LogStreamType {
|
||||
// LogStreamTypeFromProto converts a protobuf LogEntry.StreamType to the internal LogStreamType.
|
||||
func LogStreamTypeFromProto(s pb.LogEntry_StreamType) LogStreamType {
|
||||
switch s {
|
||||
case pb.ContainerLogEntry_STDOUT:
|
||||
case pb.LogEntry_STDOUT:
|
||||
return LogStreamStdout
|
||||
case pb.ContainerLogEntry_STDERR:
|
||||
case pb.LogEntry_STDERR:
|
||||
return LogStreamStderr
|
||||
case pb.ContainerLogEntry_HEARTBEAT:
|
||||
case pb.LogEntry_HEARTBEAT:
|
||||
return LogStreamHeartbeat
|
||||
default:
|
||||
return LogStreamUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// LogStreamTypeToProto converts LogStreamType to protobuf ContainerLogEntry.StreamType.
|
||||
func LogStreamTypeToProto(s LogStreamType) pb.ContainerLogEntry_StreamType {
|
||||
// LogStreamTypeToProto converts LogStreamType to protobuf LogEntry.StreamType.
|
||||
func LogStreamTypeToProto(s LogStreamType) pb.LogEntry_StreamType {
|
||||
switch s {
|
||||
case LogStreamStdout:
|
||||
return pb.ContainerLogEntry_STDOUT
|
||||
return pb.LogEntry_STDOUT
|
||||
case LogStreamStderr:
|
||||
return pb.ContainerLogEntry_STDERR
|
||||
return pb.LogEntry_STDERR
|
||||
case LogStreamHeartbeat:
|
||||
return pb.ContainerLogEntry_HEARTBEAT
|
||||
return pb.LogEntry_HEARTBEAT
|
||||
default:
|
||||
return pb.ContainerLogEntry_UNKNOWN
|
||||
return pb.LogEntry_UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ type ServiceLogsOptions struct {
|
||||
type ServiceLogEntry struct {
|
||||
// Metadata may not be set if an error occurred (Err is not nil).
|
||||
Metadata ServiceLogEntryMetadata
|
||||
ContainerLogEntry
|
||||
LogEntry
|
||||
}
|
||||
|
||||
// ServiceLogEntryMetadata contains metadata about the source of a log entry.
|
||||
@@ -73,8 +73,8 @@ type ServiceLogEntryMetadata struct {
|
||||
MachineName string
|
||||
}
|
||||
|
||||
// ContainerLogEntry represents a single log entry from a container.
|
||||
type ContainerLogEntry struct {
|
||||
// LogEntry represents a single log entry from a container or a service.
|
||||
type LogEntry struct {
|
||||
Stream LogStreamType
|
||||
Timestamp time.Time
|
||||
Message []byte
|
||||
|
||||
+57
-4
@@ -55,7 +55,7 @@ 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 is a 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
|
||||
@@ -67,6 +67,9 @@ type ServiceSpec struct {
|
||||
// Ports defines what service ports to publish to make the service accessible outside the cluster.
|
||||
// Caddy and Ports cannot be specified simultaneously.
|
||||
Ports []PortSpec
|
||||
// PreDeploy is an optional hook that runs a command in a temporary container before deploying the service.
|
||||
// The container uses the service's image and inherits its configuration.
|
||||
PreDeploy *PreDeployHook `json:",omitempty"`
|
||||
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
||||
Replicas uint `json:",omitempty"`
|
||||
// UpdateConfig configures how the service is updated during a deployment.
|
||||
@@ -205,6 +208,12 @@ func (s *ServiceSpec) Validate() error {
|
||||
return fmt.Errorf("validate service configs and mounts: %w", err)
|
||||
}
|
||||
|
||||
if s.PreDeploy != nil {
|
||||
if err := s.PreDeploy.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -216,6 +225,7 @@ func (s *ServiceSpec) Clone() ServiceSpec {
|
||||
spec.Caddy = &caddyCopy
|
||||
}
|
||||
spec.Container = s.Container.Clone()
|
||||
spec.PreDeploy = s.PreDeploy.Clone()
|
||||
|
||||
if s.Ports != nil {
|
||||
spec.Ports = make([]PortSpec, len(s.Ports))
|
||||
@@ -436,6 +446,46 @@ type LogDriver struct {
|
||||
Options map[string]string
|
||||
}
|
||||
|
||||
// PreDeployHook defines a command to run in a temporary container before deploying the service.
|
||||
// The container uses the service's image and inherits its configuration (env, volumes, placement).
|
||||
// It must exit successfully (code 0) for the deployment to proceed.
|
||||
type PreDeployHook struct {
|
||||
// Command to execute in the container.
|
||||
Command []string
|
||||
// Env defines additional environment variables for the container, merged with the service's environment variables.
|
||||
Env EnvVars `json:",omitempty"`
|
||||
// Privileged overrides the container's privileged mode. nil means inherit from the service.
|
||||
Privileged *bool `json:",omitempty"`
|
||||
// Timeout is the maximum duration to wait for the command to complete. On timeout, the container is stopped
|
||||
// and the deployment fails. If nil, a default timeout is used.
|
||||
Timeout *time.Duration `json:",omitempty"`
|
||||
// User to run the command as. If empty, the service user is used. Format: user|UID[:group|GID].
|
||||
User string `json:",omitempty"`
|
||||
}
|
||||
|
||||
func (h *PreDeployHook) Validate() error {
|
||||
if len(h.Command) == 0 {
|
||||
return fmt.Errorf("pre-deploy hook command is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *PreDeployHook) Clone() *PreDeployHook {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
hook := *h
|
||||
hook.Command = slices.Clone(h.Command)
|
||||
hook.Env = maps.Clone(h.Env)
|
||||
|
||||
return &hook
|
||||
}
|
||||
|
||||
func (h *PreDeployHook) Equals(other *PreDeployHook) bool {
|
||||
return cmp.Equal(h, other, cmpopts.EquateEmpty())
|
||||
}
|
||||
|
||||
// UpdateConfig configures how a service is updated during a deployment.
|
||||
type UpdateConfig struct {
|
||||
// Order specifies the order of operations during an update.
|
||||
@@ -455,10 +505,13 @@ type RunServiceResponse struct {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID string
|
||||
Name string
|
||||
Mode string
|
||||
ID string
|
||||
Name string
|
||||
Mode string
|
||||
// Containers is the regular long-running service containers.
|
||||
Containers []MachineServiceContainer
|
||||
// HookContainers are one-shot containers for deployment hooks (e.g. pre-deploy).
|
||||
HookContainers []MachineServiceContainer
|
||||
}
|
||||
|
||||
type MachineServiceContainer struct {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
)
|
||||
|
||||
const PreDeployHookExtensionKey = "x-pre_deploy"
|
||||
|
||||
// PreDeployHook represents the parsed x-pre_deploy extension config.
|
||||
type PreDeployHook struct {
|
||||
Command types.ShellCommand `yaml:"command" json:"command"`
|
||||
Environment types.MappingWithEquals `yaml:"environment,omitempty" json:"environment,omitempty"`
|
||||
Privileged *bool `yaml:"privileged,omitempty" json:"privileged,omitempty"`
|
||||
Timeout *types.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"`
|
||||
User string `yaml:"user,omitempty" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks that the pre-deploy hook configuration is valid.
|
||||
func (p *PreDeployHook) Validate() error {
|
||||
if len(p.Command) == 0 {
|
||||
return fmt.Errorf("missing required attribute 'command' in %s extension", PreDeployHookExtensionKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPreDeployHookExtension(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want PreDeployHook
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "command only",
|
||||
yaml: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-pre_deploy:
|
||||
command: ["echo", "hello"]
|
||||
`,
|
||||
want: PreDeployHook{
|
||||
Command: types.ShellCommand{"echo", "hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "command as string",
|
||||
yaml: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-pre_deploy:
|
||||
command: echo hello
|
||||
`,
|
||||
want: PreDeployHook{
|
||||
Command: types.ShellCommand{"echo", "hello"},
|
||||
},
|
||||
},
|
||||
// TODO: explore ways to error on unknown attributes instead of ignoring them.
|
||||
{
|
||||
name: "all attributes",
|
||||
yaml: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-pre_deploy:
|
||||
command: ["sh", "-c", "migrate up"]
|
||||
environment:
|
||||
DB_HOST: localhost
|
||||
DB_PORT: "5432"
|
||||
privileged: true
|
||||
timeout: 2m30s
|
||||
user: root
|
||||
unknown_attribute: should be ignored
|
||||
`,
|
||||
want: PreDeployHook{
|
||||
Command: types.ShellCommand{"sh", "-c", "migrate up"},
|
||||
Environment: types.MappingWithEquals{
|
||||
"DB_HOST": new("localhost"),
|
||||
"DB_PORT": new("5432"),
|
||||
},
|
||||
Privileged: new(true),
|
||||
Timeout: new(types.Duration(2*time.Minute + 30*time.Second)),
|
||||
User: "root",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "timeout as seconds",
|
||||
yaml: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-pre_deploy:
|
||||
command: ["true"]
|
||||
timeout: 30s
|
||||
`,
|
||||
want: PreDeployHook{
|
||||
Command: types.ShellCommand{"true"},
|
||||
Timeout: new(types.Duration(30 * time.Second)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "privileged false",
|
||||
yaml: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-pre_deploy:
|
||||
command: ["true"]
|
||||
privileged: false
|
||||
`,
|
||||
want: PreDeployHook{
|
||||
Command: types.ShellCommand{"true"},
|
||||
Privileged: new(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing command should fail",
|
||||
yaml: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-pre_deploy:
|
||||
user: root
|
||||
`,
|
||||
wantErr: "missing required attribute 'command'",
|
||||
},
|
||||
{
|
||||
name: "empty command should fail",
|
||||
yaml: `
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
x-pre_deploy:
|
||||
command: []
|
||||
`,
|
||||
wantErr: "missing required attribute 'command'",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
project, err := LoadProjectFromContent(context.Background(), tt.yaml)
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
service, err := project.GetService("web")
|
||||
require.NoError(t, err)
|
||||
|
||||
ext, ok := service.Extensions[PreDeployHookExtensionKey]
|
||||
require.True(t, ok, "x-pre_deploy extension not found")
|
||||
|
||||
hook, ok := ext.(PreDeployHook)
|
||||
require.True(t, ok, "x-pre_deploy extension is not PreDeployHook type")
|
||||
assert.Equal(t, tt.want, hook)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"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/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
@@ -38,6 +39,7 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
||||
composecli.WithExtension(CaddyExtensionKey, Caddy{}),
|
||||
composecli.WithExtension(MachinesExtensionKey, MachinesSource{}),
|
||||
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
||||
composecli.WithExtension(PreDeployHookExtensionKey, PreDeployHook{}),
|
||||
}
|
||||
|
||||
options, err := composecli.NewProjectOptions(
|
||||
@@ -66,6 +68,10 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, err = range validateServicesFeatures(project) {
|
||||
tui.PrintWarning(err.Error())
|
||||
}
|
||||
|
||||
// Process image templates in services to expand Go template expressions using git repo state.
|
||||
if project, err = ProcessImageTemplates(project); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -2,8 +2,10 @@ package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -186,3 +188,132 @@ REDIS_URL=redis://localhost:6379
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadProject_Unsupported checks that unsupported features lead to warnings.
|
||||
func TestLoadProject_Unsupported(t *testing.T) {
|
||||
// captureStderr runs fn while capturing stderr output and returns what was written.
|
||||
captureStderr := func(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
old := os.Stderr
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
os.Stderr = w
|
||||
defer func() { os.Stderr = old }()
|
||||
|
||||
fn()
|
||||
|
||||
w.Close()
|
||||
out, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
r.Close()
|
||||
return string(out)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
composeYAML string
|
||||
warnCount int
|
||||
warnContains []string
|
||||
}{
|
||||
{
|
||||
name: "unsupported dns",
|
||||
composeYAML: `services:
|
||||
app:
|
||||
image: myapp:latest
|
||||
dns: 8.8.8.8
|
||||
`,
|
||||
warnCount: 1,
|
||||
warnContains: []string{"dns"},
|
||||
},
|
||||
{
|
||||
name: "unsupported networks",
|
||||
composeYAML: `services:
|
||||
app:
|
||||
image: myapp:latest
|
||||
networks:
|
||||
- frontend
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
`,
|
||||
warnCount: 1,
|
||||
warnContains: []string{"networks"},
|
||||
},
|
||||
{
|
||||
name: "unsupported depends_on service_completed_successfully",
|
||||
composeYAML: `services:
|
||||
migrate:
|
||||
image: alpine
|
||||
command: ["true"]
|
||||
app:
|
||||
image: nginx
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
`,
|
||||
warnCount: 1,
|
||||
warnContains: []string{
|
||||
"service_completed_successfully",
|
||||
"pre-deploy hook",
|
||||
"https://uncloud.run/docs/guides/deployments/pre-deploy-hooks",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple unsupported features",
|
||||
composeYAML: `services:
|
||||
app:
|
||||
image: myapp:latest
|
||||
dns: 8.8.8.8
|
||||
links:
|
||||
- db
|
||||
db:
|
||||
image: postgres:latest
|
||||
secrets:
|
||||
- db_password
|
||||
|
||||
secrets:
|
||||
db_password:
|
||||
file: ./secret.txt
|
||||
`,
|
||||
warnCount: 3,
|
||||
warnContains: []string{"dns", "links", "secrets"},
|
||||
},
|
||||
{
|
||||
name: "supported networks",
|
||||
composeYAML: `services:
|
||||
app:
|
||||
image: myapp:latest
|
||||
networks:
|
||||
default: {}
|
||||
|
||||
web:
|
||||
image: nginx
|
||||
networks:
|
||||
- default
|
||||
|
||||
networks:
|
||||
default:
|
||||
`,
|
||||
warnCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stderr := captureStderr(t, func() {
|
||||
_, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
if tt.warnCount == 0 {
|
||||
assert.Empty(t, stderr)
|
||||
} else {
|
||||
assert.Equal(t, tt.warnCount, strings.Count(stderr, "WARNING:"),
|
||||
"expected %d warnings, got stderr: %s", tt.warnCount, stderr)
|
||||
for _, substr := range tt.warnContains {
|
||||
assert.Contains(t, stderr, substr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
||||
return api.ServiceSpec{}, fmt.Errorf("unsupported pull policy: '%s'", service.PullPolicy)
|
||||
}
|
||||
|
||||
env := make(map[string]string, len(service.Environment))
|
||||
env := make(api.EnvVars, len(service.Environment))
|
||||
for k, v := range service.Environment {
|
||||
if v == nil {
|
||||
// nil value means the variable misses a value in the compose file, and it hasn't been resolved
|
||||
@@ -142,6 +142,27 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
||||
spec.Configs = configSpecs
|
||||
spec.Container.ConfigMounts = configMounts
|
||||
|
||||
if h, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
|
||||
hook := &api.PreDeployHook{
|
||||
Command: h.Command,
|
||||
Privileged: h.Privileged,
|
||||
User: h.User,
|
||||
}
|
||||
if h.Environment != nil {
|
||||
hook.Env = make(api.EnvVars)
|
||||
for k, v := range h.Environment {
|
||||
if v != nil {
|
||||
hook.Env[k] = *v
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.Timeout != nil {
|
||||
d := time.Duration(*h.Timeout)
|
||||
hook.Timeout = &d
|
||||
}
|
||||
spec.PreDeploy = hook
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
@@ -418,7 +439,78 @@ func validateServicesExtensions(project *types.Project) error {
|
||||
"Host mode ports in 'x-caddy' can be used with 'x-caddy'", service.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
|
||||
if err := hook.Validate(); err != nil {
|
||||
return fmt.Errorf("service '%s': %w", service.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateServicesFeatures checks services for unsupported features and returns all found.
|
||||
func validateServicesFeatures(project *types.Project) []error {
|
||||
err := func(service, feature string) error {
|
||||
return fmt.Errorf("service '%s': unsupported feature '%s', see %s",
|
||||
service, feature, "https://uncloud.run/docs/compose-file-reference/support-matrix")
|
||||
}
|
||||
|
||||
// TODO: check other commonly used but unsupported features.
|
||||
var errs []error
|
||||
for _, service := range project.Services {
|
||||
if service.SecurityOpt != nil {
|
||||
errs = append(errs, err(service.Name, "security_opt"))
|
||||
}
|
||||
if service.DNS != nil {
|
||||
errs = append(errs, err(service.Name, "dns"))
|
||||
}
|
||||
if service.DNSSearch != nil {
|
||||
errs = append(errs, err(service.Name, "dns_search"))
|
||||
}
|
||||
if service.Labels != nil {
|
||||
errs = append(errs, err(service.Name, "labels"))
|
||||
}
|
||||
if service.Links != nil {
|
||||
errs = append(errs, err(service.Name, "links"))
|
||||
}
|
||||
if service.MemSwappiness > 0 {
|
||||
errs = append(errs, err(service.Name, "mem_swappiness"))
|
||||
}
|
||||
if service.MemSwapLimit > 0 {
|
||||
errs = append(errs, err(service.Name, "memswap_limit"))
|
||||
}
|
||||
if service.Secrets != nil {
|
||||
errs = append(errs, err(service.Name, "secrets"))
|
||||
}
|
||||
if service.StorageOpt != nil {
|
||||
errs = append(errs, err(service.Name, "storage_opt"))
|
||||
}
|
||||
// we only allow the 'default' network, nothing else.
|
||||
if x := service.Networks; x != nil {
|
||||
if len(x) != 1 {
|
||||
errs = append(errs, err(service.Name, "networks"))
|
||||
} else if _, ok := x["default"]; !ok {
|
||||
errs = append(errs, err(service.Name, "networks"))
|
||||
}
|
||||
}
|
||||
|
||||
// Err about depends_on conditions 'service_completed_successfully' that we do not support because services in
|
||||
// Uncloud are long-running. Services that run to completion need a separate abstraction, e.g. a Job.
|
||||
// Plus we don't want the lifecycle of a service to control the lifecycle of another one. It should be fully
|
||||
// owned. Otherwise, it's hard to make the behaviour deterministic when each service could also be deployed and
|
||||
// managed independently.
|
||||
for depName, dep := range service.DependsOn {
|
||||
if dep.Condition == types.ServiceConditionCompletedSuccessfully {
|
||||
errs = append(errs, fmt.Errorf(
|
||||
"service '%s': depends_on condition '%s' on service '%s' is not supported, "+
|
||||
"use a pre-deploy hook instead: %s",
|
||||
service.Name, types.ServiceConditionCompletedSuccessfully, depName,
|
||||
"https://uncloud.run/docs/guides/deployments/pre-deploy-hooks"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -208,6 +208,13 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
Placement: api.Placement{
|
||||
Machines: []string{"machine-1", "machine-2"},
|
||||
},
|
||||
PreDeploy: &api.PreDeployHook{
|
||||
Command: []string{"sh", "-c", "migrate"},
|
||||
Env: api.EnvVars{"DB_HOST": "localhost"},
|
||||
Privileged: new(false),
|
||||
Timeout: new(2*time.Minute + 30*time.Second),
|
||||
User: "root",
|
||||
},
|
||||
Replicas: 3,
|
||||
UpdateConfig: api.UpdateConfig{
|
||||
Order: api.UpdateOrderStopFirst,
|
||||
@@ -968,7 +975,7 @@ services:
|
||||
monitor: 10s
|
||||
`,
|
||||
expected: api.UpdateConfig{
|
||||
MonitorPeriod: api.AsPtr(10 * time.Second),
|
||||
MonitorPeriod: new(10 * time.Second),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -984,7 +991,7 @@ services:
|
||||
`,
|
||||
expected: api.UpdateConfig{
|
||||
Order: api.UpdateOrderStartFirst,
|
||||
MonitorPeriod: api.AsPtr(30 * time.Second),
|
||||
MonitorPeriod: new(30 * time.Second),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -998,7 +1005,7 @@ services:
|
||||
monitor: 0s
|
||||
`,
|
||||
expected: api.UpdateConfig{
|
||||
MonitorPeriod: api.AsPtr(time.Duration(0)),
|
||||
MonitorPeriod: new(time.Duration(0)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -78,6 +78,13 @@ services:
|
||||
- test.example.com:80/https
|
||||
- 8000/http
|
||||
- 5000:3000@host
|
||||
x-pre_deploy:
|
||||
command: ["sh", "-c", "migrate"]
|
||||
environment:
|
||||
DB_HOST: localhost
|
||||
privileged: false
|
||||
timeout: 2m30s
|
||||
user: root
|
||||
|
||||
test-caddy-config:
|
||||
image: myapp:1.2.3
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/grpcversion"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/sshexec"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -74,6 +75,8 @@ func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
||||
"unix://"+sockPath,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultServiceConfig(defaultServiceConfig),
|
||||
grpc.WithUnaryInterceptor(grpcversion.ClientUnaryInterceptor),
|
||||
grpc.WithStreamInterceptor(grpcversion.ClientStreamInterceptor),
|
||||
grpc.WithContextDialer(
|
||||
func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
addr = strings.TrimPrefix(addr, "unix://")
|
||||
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli/connhelper/commandconn"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/grpcversion"
|
||||
"golang.org/x/net/proxy"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -61,17 +63,32 @@ func controlSocketPath() string {
|
||||
}
|
||||
|
||||
func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
||||
// 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.
|
||||
// Validate SSH connectivity by running a no-op command on the remote machine. This also
|
||||
// establishes the control socket (ControlMaster=auto) so subsequent connections reuse it.
|
||||
probeArgs := append(c.buildSSHArgs(), "true")
|
||||
probe := exec.CommandContext(ctx, "ssh", probeArgs...)
|
||||
if output, err := probe.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("SSH connection to '%s': %w: %s",
|
||||
c.config.Destination(), err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
// Create gRPC client with a dialer that spawns new SSH connections on demand,
|
||||
// reusing the control socket established above.
|
||||
grpcConn, err := grpc.NewClient(
|
||||
"passthrough:///", // Dummy target since we're using a custom dialer.
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultServiceConfig(defaultServiceConfig),
|
||||
grpc.WithUnaryInterceptor(grpcversion.ClientUnaryInterceptor),
|
||||
grpc.WithStreamInterceptor(grpcversion.ClientStreamInterceptor),
|
||||
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
args := c.buildSSHArgs()
|
||||
conn, err := commandconn.New(ctx, "ssh", args...)
|
||||
dialArgs := append(c.buildSSHArgs(), "uncloudd", "dial-stdio")
|
||||
if c.config.SockPath != "" {
|
||||
dialArgs = append(dialArgs, "--socket", c.config.SockPath)
|
||||
}
|
||||
|
||||
conn, err := commandconn.New(ctx, "ssh", dialArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH connection to %s: %w", c.config.Destination(), err)
|
||||
return nil, fmt.Errorf("SSH connection to '%s': %w", c.config.Destination(), err)
|
||||
}
|
||||
return conn, nil
|
||||
}),
|
||||
@@ -83,8 +100,9 @@ func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error)
|
||||
return grpcConn, nil
|
||||
}
|
||||
|
||||
// buildSSHArgs constructs the SSH command arguments to run `uncloudd dial-stdio` on the remote machine reusing
|
||||
// the established connection via control socket.
|
||||
// buildSSHArgs constructs the SSH command arguments with connection options and destination. The options
|
||||
// include control socket settings for connection reuse if necessary.
|
||||
// The remote command is not included and should be appended by the caller.
|
||||
func (c *SSHCLIConnector) buildSSHArgs() []string {
|
||||
var args []string
|
||||
|
||||
@@ -104,6 +122,9 @@ func (c *SSHCLIConnector) buildSSHArgs() []string {
|
||||
|
||||
// Add connection timeout to fail fast when node is down.
|
||||
args = append(args, "-o", "ConnectTimeout=5")
|
||||
// Disable interactive prompts (e.g., passphrase input) to prevent interference with the TUI.
|
||||
// Authentication must succeed non-interactively via SSH agent or unencrypted key.
|
||||
args = append(args, "-o", "BatchMode=yes")
|
||||
// Disable pseudo-terminal allocation to prevent SSH from executing as a login shell.
|
||||
args = append(args, "-T")
|
||||
|
||||
@@ -120,14 +141,6 @@ func (c *SSHCLIConnector) buildSSHArgs() []string {
|
||||
// Add [user@]host destination.
|
||||
args = append(args, c.config.Destination())
|
||||
|
||||
// Add remote command: uncloudd dial-stdio
|
||||
args = append(args, "uncloudd", "dial-stdio")
|
||||
|
||||
// Add socket path if non-default.
|
||||
if c.config.SockPath != "" && c.config.SockPath != machine.DefaultUncloudSockPath {
|
||||
args = append(args, "--socket", c.config.SockPath)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -137,10 +150,22 @@ func (c *SSHCLIConnector) Dialer() (proxy.ContextDialer, error) {
|
||||
return nil, fmt.Errorf("SSH connector not configured")
|
||||
}
|
||||
|
||||
return &sshCLIDialer{
|
||||
config: c.config,
|
||||
controlSockPath: c.controlSockPath,
|
||||
}, nil
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DialContext establishes a connection to the target address through an SSH tunnel using -W flag.
|
||||
func (c *SSHCLIConnector) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
if network != "tcp" {
|
||||
return nil, fmt.Errorf("unsupported network type: %s", network)
|
||||
}
|
||||
|
||||
args := append(c.buildSSHArgs(), "-W", address)
|
||||
conn, err := commandconn.New(ctx, "ssh", args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH connection to '%s' for dialing '%s': %w", c.config.Destination(), address, err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *SSHCLIConnector) Close() error {
|
||||
@@ -148,64 +173,3 @@ func (c *SSHCLIConnector) Close() error {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -24,7 +23,7 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
Host: "example.com",
|
||||
},
|
||||
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"},
|
||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-T", "root@example.com"},
|
||||
},
|
||||
{
|
||||
name: "basic connection without control socket",
|
||||
@@ -33,7 +32,7 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
Host: "example.com",
|
||||
},
|
||||
controlSockPath: "",
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-T", "root@example.com", "uncloudd", "dial-stdio"},
|
||||
expected: []string{"-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-T", "root@example.com"},
|
||||
},
|
||||
{
|
||||
name: "with custom port",
|
||||
@@ -43,7 +42,7 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
Port: 2222,
|
||||
},
|
||||
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"},
|
||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-T", "-p", "2222", "root@example.com"},
|
||||
},
|
||||
{
|
||||
name: "with identity file",
|
||||
@@ -53,27 +52,7 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
KeyPath: "/path/to/key",
|
||||
},
|
||||
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",
|
||||
config: SSHConnectorConfig{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
SockPath: "/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)",
|
||||
config: SSHConnectorConfig{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
SockPath: machine.DefaultUncloudSockPath,
|
||||
},
|
||||
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"},
|
||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-T", "-i", "/path/to/key", "root@example.com"},
|
||||
},
|
||||
{
|
||||
name: "all options combined",
|
||||
@@ -85,7 +64,7 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
SockPath: "/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"},
|
||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-T", "-p", "2222", "-i", "/path/to/key", "root@example.com"},
|
||||
},
|
||||
{
|
||||
name: "port 0 not included",
|
||||
@@ -95,7 +74,7 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
Port: 0,
|
||||
},
|
||||
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"},
|
||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-T", "root@example.com"},
|
||||
},
|
||||
{
|
||||
name: "port 22 included when explicit",
|
||||
@@ -105,7 +84,7 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
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"},
|
||||
expected: []string{"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/test.sock", "-o", "ControlPersist=10m", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", "-T", "-p", "22", "root@example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -120,95 +99,6 @@ func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCLIDialer_buildDialArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config SSHConnectorConfig
|
||||
controlSockPath string
|
||||
address string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "basic connection without control socket",
|
||||
config: SSHConnectorConfig{
|
||||
User: "root",
|
||||
Host: "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: "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,
|
||||
},
|
||||
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 and control socket",
|
||||
config: SSHConnectorConfig{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
Port: 22,
|
||||
KeyPath: "/home/user/.ssh/id_rsa",
|
||||
},
|
||||
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 and control socket",
|
||||
config: SSHConnectorConfig{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
Port: 2222,
|
||||
KeyPath: "/home/user/.ssh/id_rsa",
|
||||
},
|
||||
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: "port 0 not included",
|
||||
config: SSHConnectorConfig{
|
||||
User: "root",
|
||||
Host: "example.com",
|
||||
Port: 0,
|
||||
},
|
||||
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"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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().
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/grpcversion"
|
||||
"golang.org/x/net/proxy"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/backoff"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
@@ -20,10 +23,21 @@ func NewTCPConnector(apiAddr netip.AddrPort) *TCPConnector {
|
||||
}
|
||||
|
||||
func (c *TCPConnector) Connect(_ context.Context) (*grpc.ClientConn, error) {
|
||||
// Use a faster connection backoff than the default (which grows up to 120s). TCP connections are typically to
|
||||
// local Docker containers (ucind) or LAN machines where long backoff delays are unnecessary.
|
||||
backoffConfig := backoff.DefaultConfig
|
||||
backoffConfig.MaxDelay = 5 * time.Second
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
c.apiAddr.String(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultServiceConfig(defaultServiceConfig),
|
||||
grpc.WithConnectParams(grpc.ConnectParams{
|
||||
Backoff: backoffConfig,
|
||||
MinConnectTimeout: 5 * time.Second,
|
||||
}),
|
||||
grpc.WithUnaryInterceptor(grpcversion.ClientUnaryInterceptor),
|
||||
grpc.WithStreamInterceptor(grpcversion.ClientStreamInterceptor),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create machine API client: %w", err)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/grpcversion"
|
||||
"golang.org/x/net/proxy"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -26,6 +27,8 @@ func (c *UnixConnector) Connect(_ context.Context) (*grpc.ClientConn, error) {
|
||||
target,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultServiceConfig(defaultServiceConfig),
|
||||
grpc.WithUnaryInterceptor(grpcversion.ClientUnaryInterceptor),
|
||||
grpc.WithStreamInterceptor(grpcversion.ClientStreamInterceptor),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create machine API client: %w", err)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/grpcversion"
|
||||
"github.com/psviderski/uncloud/internal/machine/constants"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/network/tunnel"
|
||||
@@ -70,6 +71,8 @@ func (c *WireGuardConnector) Connect(ctx context.Context) (*grpc.ClientConn, err
|
||||
grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
return c.tun.DialContext(ctx, "tcp", addr)
|
||||
}),
|
||||
grpc.WithUnaryInterceptor(grpcversion.ClientUnaryInterceptor),
|
||||
grpc.WithStreamInterceptor(grpcversion.ClientStreamInterceptor),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to machine API through WireGuard tunnel: %w", err)
|
||||
|
||||
+61
-13
@@ -2,19 +2,22 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
cliprogress "github.com/psviderski/uncloud/internal/cli/progress"
|
||||
"github.com/psviderski/uncloud/internal/docker"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
@@ -24,8 +27,29 @@ import (
|
||||
// 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,
|
||||
) (container.CreateResponse, error) {
|
||||
var resp container.CreateResponse
|
||||
) (api.CreateContainerResponse, error) {
|
||||
return cli.createServiceContainerWithPull(ctx, serviceID, spec, machineID, pb.CreateServiceContainerRequest_SERVICE)
|
||||
}
|
||||
|
||||
// CreatePreDeployHookContainer creates a one-shot container for a pre-deploy hook for the given service
|
||||
// on the specified machine.
|
||||
func (cli *Client) CreatePreDeployHookContainer(
|
||||
ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string,
|
||||
) (api.CreateContainerResponse, error) {
|
||||
return cli.createServiceContainerWithPull(
|
||||
ctx, serviceID, spec, machineID, pb.CreateServiceContainerRequest_PRE_DEPLOY)
|
||||
}
|
||||
|
||||
// createServiceContainerWithPull creates a regular or deployment hook container for the service
|
||||
// on the specified machine, pulling the image if needed.
|
||||
func (cli *Client) createServiceContainerWithPull(
|
||||
ctx context.Context,
|
||||
serviceID string,
|
||||
spec api.ServiceSpec,
|
||||
machineID string,
|
||||
containerType pb.CreateServiceContainerRequest_ContainerType,
|
||||
) (api.CreateContainerResponse, error) {
|
||||
var resp api.CreateContainerResponse
|
||||
|
||||
spec = spec.SetDefaults()
|
||||
if err := spec.Validate(); err != nil {
|
||||
@@ -42,13 +66,18 @@ func (cli *Client) CreateContainer(
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("generate random suffix: %w", err)
|
||||
}
|
||||
|
||||
containerName := fmt.Sprintf("%s-%s", spec.Name, suffix)
|
||||
if containerType == pb.CreateServiceContainerRequest_PRE_DEPLOY {
|
||||
containerName = fmt.Sprintf("%s-%s-%s", spec.Name, api.LabelHookPreDeploy, suffix)
|
||||
}
|
||||
resp.Name = containerName
|
||||
|
||||
// Proxy Docker gRPC requests to the selected machine.
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Container %s on %s", containerName, machine.Machine.Name)
|
||||
eventID := cliprogress.NewContainerEventID(ctx, containerName, machine.Machine.Name)
|
||||
pw.Event(progress.CreatingEvent(eventID))
|
||||
|
||||
if spec.Container.PullPolicy == api.PullPolicyAlways {
|
||||
@@ -57,7 +86,18 @@ func (cli *Client) CreateContainer(
|
||||
}
|
||||
}
|
||||
|
||||
resp, err = cli.Docker.CreateServiceContainer(ctx, serviceID, spec, containerName)
|
||||
specBytes, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("marshal service spec: %w", err)
|
||||
}
|
||||
req := &pb.CreateServiceContainerRequest{
|
||||
ServiceId: serviceID,
|
||||
ServiceSpec: specBytes,
|
||||
ContainerName: containerName,
|
||||
ContainerType: containerType,
|
||||
}
|
||||
|
||||
grpcResp, err := cli.Docker.GRPCClient.CreateServiceContainer(ctx, req)
|
||||
if err != nil {
|
||||
switch spec.Container.PullPolicy {
|
||||
case api.PullPolicyAlways, api.PullPolicyNever:
|
||||
@@ -68,7 +108,7 @@ func (cli *Client) CreateContainer(
|
||||
}
|
||||
|
||||
// NotFound (No such image) error is expected if the image is missing.
|
||||
if !errdefs.IsNotFound(err) || !strings.Contains(err.Error(), "No such image") {
|
||||
if status.Code(err) != codes.NotFound || !strings.Contains(err.Error(), "No such image") {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -76,10 +116,14 @@ func (cli *Client) CreateContainer(
|
||||
if err = cli.pullImageWithProgress(ctx, spec.Container.Image, machine.Machine.Name, eventID); err != nil {
|
||||
return resp, err
|
||||
}
|
||||
if resp, err = cli.Docker.CreateServiceContainer(ctx, serviceID, spec, containerName); err != nil {
|
||||
if grpcResp, err = cli.Docker.GRPCClient.CreateServiceContainer(ctx, req); err != nil {
|
||||
return resp, err
|
||||
}
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(grpcResp.Response, &resp.CreateResponse); err != nil {
|
||||
return resp, fmt.Errorf("unmarshal gRPC response: %w", err)
|
||||
}
|
||||
pw.Event(progress.CreatedEvent(eventID))
|
||||
|
||||
return resp, nil
|
||||
@@ -87,7 +131,7 @@ func (cli *Client) CreateContainer(
|
||||
|
||||
func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName, parentEventID string) error {
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Image %s on %s", image, machineName)
|
||||
eventID := cliprogress.ImageEventID(image, machineName)
|
||||
pw.Event(progress.Event{
|
||||
ID: eventID,
|
||||
ParentID: parentEventID,
|
||||
@@ -213,7 +257,7 @@ func (cli *Client) InspectContainer(
|
||||
}
|
||||
|
||||
prefixMatchCandidates := []api.MachineServiceContainer{}
|
||||
for _, c := range svc.Containers {
|
||||
for _, c := range append(svc.Containers, svc.HookContainers...) {
|
||||
if c.Container.ID == containerNameOrID ||
|
||||
c.Container.Name == containerNameOrID {
|
||||
return c, nil
|
||||
@@ -248,7 +292,7 @@ func (cli *Client) StartContainer(ctx context.Context, serviceNameOrID, containe
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name)
|
||||
eventID := cliprogress.ContainerEventID(ctx, ctr.Container.ServiceSpec.Name, ctr.Container.ID, machine.Machine.Name)
|
||||
|
||||
pw.Event(progress.StartingEvent(eventID))
|
||||
if err = cli.Docker.StartContainer(ctx, ctr.Container.ID, container.StartOptions{}); err != nil {
|
||||
@@ -260,6 +304,8 @@ func (cli *Client) StartContainer(ctx context.Context, serviceNameOrID, containe
|
||||
}
|
||||
|
||||
// StopContainer stops the specified container within the service.
|
||||
//
|
||||
//nolint:dupl // Structurally similar to RemoveContainer but performs a different operation.
|
||||
func (cli *Client) StopContainer(
|
||||
ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions,
|
||||
) error {
|
||||
@@ -275,7 +321,7 @@ func (cli *Client) StopContainer(
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name)
|
||||
eventID := cliprogress.ContainerEventID(ctx, ctr.Container.ServiceSpec.Name, ctr.Container.ID, machine.Machine.Name)
|
||||
|
||||
pw.Event(progress.StoppingEvent(eventID))
|
||||
if err = cli.Docker.StopContainer(ctx, ctr.Container.ID, opts); err != nil {
|
||||
@@ -287,6 +333,8 @@ func (cli *Client) StopContainer(
|
||||
}
|
||||
|
||||
// RemoveContainer removes the specified container within the service.
|
||||
//
|
||||
//nolint:dupl // Structurally similar to StopContainer but performs a different operation.
|
||||
func (cli *Client) RemoveContainer(
|
||||
ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions,
|
||||
) error {
|
||||
@@ -302,7 +350,7 @@ func (cli *Client) RemoveContainer(
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name)
|
||||
eventID := cliprogress.ContainerEventID(ctx, ctr.Container.ServiceSpec.Name, ctr.Container.ID, machine.Machine.Name)
|
||||
|
||||
pw.Event(progress.RemovingEvent(eventID))
|
||||
if err = cli.Docker.RemoveServiceContainer(ctx, ctr.Container.ID, opts); err != nil {
|
||||
@@ -382,7 +430,7 @@ func (cli *Client) WaitContainerHealthy(
|
||||
}
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Container %s on %s", mc.Container.Name, machine.Machine.Name)
|
||||
eventID := cliprogress.ContainerEventID(ctx, mc.Container.ServiceSpec.Name, mc.Container.ID, machine.Machine.Name)
|
||||
|
||||
var monitor time.Duration
|
||||
if opts.MonitorPeriod == nil {
|
||||
|
||||
@@ -114,7 +114,7 @@ func (sp *ServicePlan) Format() string {
|
||||
if sp.IsNewService {
|
||||
specTable.Row("", "image:", tui.FormatImage(sp.Spec.Container.Image, tui.Green))
|
||||
} else {
|
||||
specTable.Row("", "image:", tui.FormatImage(sp.Spec.Container.Image, lipgloss.NewStyle()))
|
||||
specTable.Row("", "image:", tui.FormatImage(sp.Spec.Container.Image, tui.NoStyle))
|
||||
}
|
||||
} else {
|
||||
mod := ""
|
||||
@@ -233,7 +233,7 @@ func formatImageDiff(oldImage, newImage string) string {
|
||||
}
|
||||
|
||||
if oldImage == newImage {
|
||||
return tui.FormatImage(newImage, lipgloss.NewStyle())
|
||||
return tui.FormatImage(newImage, tui.NoStyle)
|
||||
}
|
||||
|
||||
oldRef, _ := reference.ParseDockerRef(oldImage)
|
||||
@@ -337,7 +337,7 @@ func (d *Deployment) Validate(ctx context.Context) error {
|
||||
return fmt.Errorf("invalid service spec: %w", err)
|
||||
}
|
||||
|
||||
if d.Service == nil {
|
||||
if d.Service == nil && d.Spec.Name != "" {
|
||||
svc, err := d.cli.InspectService(ctx, d.Spec.Name)
|
||||
if err == nil {
|
||||
d.Service = &svc
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
cliprogress "github.com/psviderski/uncloud/internal/cli/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
@@ -28,6 +29,10 @@ func (o *RunContainerOperation) Execute(ctx context.Context, cli Client) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("create container: %w", err)
|
||||
}
|
||||
// Override event ID so StartContainer and WaitContainerHealthy update the same progress line as creation.
|
||||
// TODO: This is a hack to work around the limitations of the compose progress library.
|
||||
// We likely need to fork or create our own to decouple event IDs from presentation layer.
|
||||
ctx = cliprogress.WithEventID(ctx, cliprogress.NewContainerEventID(ctx, resp.Name, o.MachineName))
|
||||
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
|
||||
return fmt.Errorf("start container: %w", err)
|
||||
}
|
||||
@@ -171,13 +176,15 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err
|
||||
if err != nil {
|
||||
return fmt.Errorf("create new container: %w", err)
|
||||
}
|
||||
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
|
||||
// Override event ID so StartContainer and WaitContainerHealthy update the same progress line as creation.
|
||||
newCtx := cliprogress.WithEventID(ctx, cliprogress.NewContainerEventID(ctx, resp.Name, o.MachineName))
|
||||
if err = cli.StartContainer(newCtx, 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 {
|
||||
if err = cli.WaitContainerHealthy(newCtx, 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.
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
cliprogress "github.com/psviderski/uncloud/internal/cli/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
// DefaultPreDeployTimeout is the maximum duration to wait for a pre-deploy hook container to complete.
|
||||
const DefaultPreDeployTimeout = 5 * time.Minute
|
||||
|
||||
// StopPreDeployOperation stops a running pre-deploy hook container from a previous deployment.
|
||||
type StopPreDeployOperation struct {
|
||||
MachineID string
|
||||
// MachineName is used for formatting the operation as part of the deployment plan.
|
||||
MachineName string
|
||||
Container api.ServiceContainer
|
||||
}
|
||||
|
||||
func (o *StopPreDeployOperation) Execute(ctx context.Context, cli Client) error {
|
||||
ctx = cliprogress.WithEventID(ctx,
|
||||
cliprogress.OldPreDeployHookEventID(o.Container.ServiceName(), o.Container.ID, o.MachineName))
|
||||
if err := cli.StopContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.StopOptions{}); err != nil {
|
||||
if !errdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("stop pre-deploy hook container '%s': %w", o.Container.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *StopPreDeployOperation) Format() string {
|
||||
displayName := o.Container.ServiceSpec.Name + tui.Faint.Render("/") + o.Container.ShortID()
|
||||
status, _ := o.Container.HumanState()
|
||||
|
||||
return tui.BoldRed.Render("⏹") + " " +
|
||||
tui.Faint.Render("stop pre-deploy hook") + " " +
|
||||
displayName + " " +
|
||||
tui.Faint.Render("("+status+")") + " " +
|
||||
tui.Faint.Render("on") + " " +
|
||||
o.MachineName
|
||||
}
|
||||
|
||||
func (o *StopPreDeployOperation) String() string {
|
||||
return fmt.Sprintf("StopPreDeployOperation[machine_id=%s container_id=%s]",
|
||||
o.MachineID, o.Container.ID)
|
||||
}
|
||||
|
||||
// RunPreDeployOperation runs a one-shot pre-deploy hook container before service deployment.
|
||||
type RunPreDeployOperation struct {
|
||||
ServiceID string
|
||||
Spec api.ServiceSpec
|
||||
MachineID string
|
||||
// MachineName is used for formatting the operation as part of the deployment plan.
|
||||
MachineName string
|
||||
// OldContainerIDs are pre-deploy hook containers from previous deployments to remove.
|
||||
OldContainerIDs []string
|
||||
}
|
||||
|
||||
func (o *RunPreDeployOperation) Execute(ctx context.Context, cli Client) error {
|
||||
// Remove old pre-deploy containers.
|
||||
for _, id := range o.OldContainerIDs {
|
||||
oldCtx := cliprogress.WithEventID(ctx, cliprogress.OldPreDeployHookEventID(o.Spec.Name, id, o.MachineName))
|
||||
_ = cli.StopContainer(oldCtx, o.ServiceID, id, container.StopOptions{})
|
||||
err := cli.RemoveContainer(oldCtx, o.ServiceID, id, container.RemoveOptions{RemoveVolumes: true})
|
||||
if err != nil && !errdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("remove old pre-deploy hook container '%s': %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the event ID override so all downstream client methods (create, start, stop) use the same
|
||||
// pre-deploy hook progress event instead of the generic container one.
|
||||
ctx = cliprogress.WithEventID(ctx, cliprogress.PreDeployHookEventID(o.Spec.Name, o.MachineName))
|
||||
|
||||
resp, err := cli.CreatePreDeployHookContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create pre-deploy hook container: %w", err)
|
||||
}
|
||||
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
|
||||
return fmt.Errorf("start pre-deploy hook container: %w", err)
|
||||
}
|
||||
|
||||
timeout := DefaultPreDeployTimeout
|
||||
if o.Spec.PreDeploy.Timeout != nil {
|
||||
timeout = *o.Spec.PreDeploy.Timeout
|
||||
}
|
||||
|
||||
return o.waitForExit(ctx, cli, resp.ID, timeout)
|
||||
}
|
||||
|
||||
// waitForExit polls the container state until it exits or the timeout is reached.
|
||||
func (o *RunPreDeployOperation) waitForExit(
|
||||
ctx context.Context, cli Client, containerID string, timeout time.Duration,
|
||||
) error {
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := cliprogress.PreDeployHookEventID(o.Spec.Name, o.MachineName)
|
||||
pw.Event(progress.Waiting(eventID))
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
var ctr *api.ServiceContainer
|
||||
for {
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
// Stop the container on timeout or context cancellation.
|
||||
_ = cli.StopContainer(ctx, o.ServiceID, containerID, container.StopOptions{})
|
||||
|
||||
ctrID := containerID
|
||||
if ctr != nil {
|
||||
ctrID = fmt.Sprintf("%s/%s", o.Spec.Name, ctr.ShortID())
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
// The parent context has been cancelled before the timeout.
|
||||
pw.Event(progress.NewEvent(eventID, progress.Error, "Cancelled"))
|
||||
return fmt.Errorf("pre-deploy hook container '%s': %w", ctrID, ctx.Err())
|
||||
}
|
||||
pw.Event(progress.NewEvent(eventID, progress.Error, fmt.Sprintf("Timeout (%s)", timeout)))
|
||||
return fmt.Errorf("pre-deploy hook container '%s' timed out after %s. "+
|
||||
"It's stopped and available for inspection. Fetch logs with 'uc logs %s'",
|
||||
ctrID, timeout, o.Spec.Name)
|
||||
|
||||
case <-ticker.C:
|
||||
mc, err := cli.InspectContainer(ctx, o.ServiceID, containerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect pre-deploy hook container: %w", err)
|
||||
}
|
||||
ctr = &mc.Container
|
||||
|
||||
if ctr.State.Running {
|
||||
if state, err := ctr.Container.HumanState(); err == nil {
|
||||
pw.Event(progress.NewEvent(eventID, progress.Working, fmt.Sprintf("Waiting (%s)", state)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Hook container has exited successfully.
|
||||
if ctr.State.ExitCode == 0 {
|
||||
pw.Event(progress.Event{
|
||||
ID: eventID,
|
||||
Status: progress.Done,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
pw.Event(progress.ErrorEvent(eventID))
|
||||
ctrID := fmt.Sprintf("%s/%s", o.Spec.Name, ctr.ShortID())
|
||||
return fmt.Errorf("pre-deploy hook container '%s' failed with exit code: %d. "+
|
||||
"It's stopped and available for inspection. Fetch logs with 'uc logs %s'",
|
||||
ctrID, ctr.State.ExitCode, o.Spec.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *RunPreDeployOperation) Format() string {
|
||||
cmd := strings.Join(o.Spec.PreDeploy.Command, " ")
|
||||
|
||||
timeout := DefaultPreDeployTimeout
|
||||
if o.Spec.PreDeploy.Timeout != nil {
|
||||
timeout = *o.Spec.PreDeploy.Timeout
|
||||
}
|
||||
|
||||
prefix := tui.BoldGreen.Render("▶") + " " +
|
||||
tui.Faint.Render("run pre-deploy hook") + " " +
|
||||
o.Spec.Name + " ["
|
||||
suffix := "] " +
|
||||
tui.Faint.Render("on") + " " +
|
||||
o.MachineName + " " +
|
||||
tui.Yellow.Render(fmt.Sprintf("(timeout %s)", timeout))
|
||||
|
||||
// Truncate the command to fit within the terminal width.
|
||||
termWidth := tui.TerminalWidth()
|
||||
if termWidth > 0 {
|
||||
buffer := 10
|
||||
maxCmdWidth := termWidth - lipgloss.Width(prefix) - lipgloss.Width(suffix) - buffer
|
||||
if maxCmdWidth <= 0 {
|
||||
maxCmdWidth = 10
|
||||
}
|
||||
cmd = ansi.Truncate(cmd, maxCmdWidth, "…")
|
||||
}
|
||||
|
||||
return prefix + cmd + suffix
|
||||
}
|
||||
|
||||
func (o *RunPreDeployOperation) String() string {
|
||||
return fmt.Sprintf("RunPreDeployOperation[machine_id=%s service_id=%s cmd=%v]",
|
||||
o.MachineID, o.ServiceID, o.Spec.PreDeploy.Command)
|
||||
}
|
||||
@@ -67,3 +67,11 @@ func (s *ClusterState) Machine(nameOrID string) (*Machine, bool) {
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// MachineName returns the machine name by ID from the cluster state. If the id is not found, ("", false) is returned.
|
||||
func (s *ClusterState) MachineName(id string) (string, bool) {
|
||||
if m, ok := s.Machine(id); ok {
|
||||
return m.Info.Name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -199,6 +199,10 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
||||
}
|
||||
}
|
||||
|
||||
if ops := s.preDeployOperations(svc, plan); len(ops) > 0 {
|
||||
plan.Operations = append(ops, plan.Operations...)
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
@@ -265,6 +269,10 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Se
|
||||
}
|
||||
}
|
||||
|
||||
if ops := s.preDeployOperations(svc, plan); len(ops) > 0 {
|
||||
plan.Operations = append(ops, plan.Operations...)
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
@@ -324,6 +332,7 @@ func reconcileGlobalContainer(
|
||||
break
|
||||
}
|
||||
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||
// Make sure to update preDeployOperation accordingly.
|
||||
}
|
||||
if upToDate {
|
||||
return ops, nil
|
||||
@@ -435,6 +444,64 @@ func determineUpdateOrder(oldContainer api.ServiceContainer, spec api.ServiceSpe
|
||||
return api.UpdateOrderStartFirst
|
||||
}
|
||||
|
||||
// preDeployOperations returns operations for the pre-deploy hook if the spec has one and the plan updates the service.
|
||||
// It prepends StopPreDeployOperations for any running hook containers, followed by a RunPreDeployOperation on the same
|
||||
// machine as the first run/replace operation.
|
||||
func (s *RollingStrategy) preDeployOperations(svc *api.Service, plan ServicePlan) []operation.Operation {
|
||||
if plan.Spec.PreDeploy == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the first run or replace operation to determine the target machine.
|
||||
var machineID, machineName string
|
||||
for _, op := range plan.Operations {
|
||||
switch o := op.(type) {
|
||||
case *operation.RunContainerOperation:
|
||||
machineID = o.MachineID
|
||||
machineName = o.MachineName
|
||||
case *operation.ReplaceContainerOperation:
|
||||
machineID = o.MachineID
|
||||
machineName = o.MachineName
|
||||
default:
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Skip the hook as there are no run or replace operations.
|
||||
if machineID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ops []operation.Operation
|
||||
|
||||
// Collect old pre-deploy container IDs to clean up and stop any that are still running.
|
||||
var oldContainerIDs []string
|
||||
if svc != nil {
|
||||
for _, c := range svc.HookContainers {
|
||||
oldContainerIDs = append(oldContainerIDs, c.Container.ID)
|
||||
if c.Container.State.Running {
|
||||
hookMachineName, _ := s.state.MachineName(c.MachineID)
|
||||
ops = append(ops, &operation.StopPreDeployOperation{
|
||||
MachineID: c.MachineID,
|
||||
MachineName: hookMachineName,
|
||||
Container: c.Container,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ops = append(ops, &operation.RunPreDeployOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
Spec: plan.Spec,
|
||||
MachineID: machineID,
|
||||
MachineName: machineName,
|
||||
OldContainerIDs: oldContainerIDs,
|
||||
})
|
||||
|
||||
return ops
|
||||
}
|
||||
|
||||
// newEmptyServicePlan creates a new empty plan for a service deployment with initialised service ID and name.
|
||||
func newEmptyServicePlan(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
|
||||
plan := ServicePlan{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -423,6 +424,246 @@ func TestReconcileGlobalContainer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreDeployOperations(t *testing.T) {
|
||||
hook := &api.PreDeployHook{
|
||||
Command: []string{"db", "migrate"},
|
||||
}
|
||||
|
||||
strategy := &RollingStrategy{
|
||||
state: &scheduler.ClusterState{
|
||||
Machines: []*scheduler.Machine{
|
||||
{Info: &pb.MachineInfo{Id: "m-1", Name: "machine-1"}},
|
||||
{Info: &pb.MachineInfo{Id: "m-2", Name: "machine-2"}},
|
||||
{Info: &pb.MachineInfo{Id: "m-3", Name: "machine-3"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runningHook1 := newServiceContainer("running-hook-1", container.State{Running: true, Status: "running"})
|
||||
runningHook2 := newServiceContainer("running-hook-2", container.State{Running: true, Status: "running"})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
plan ServicePlan
|
||||
svc *api.Service
|
||||
expected []operation.Operation
|
||||
}{
|
||||
{
|
||||
name: "no pre-deploy hook in spec",
|
||||
plan: ServicePlan{
|
||||
ServiceID: "svc-1",
|
||||
ServiceName: "app",
|
||||
Spec: api.ServiceSpec{},
|
||||
SequenceOperation: operation.SequenceOperation{
|
||||
Operations: []operation.Operation{
|
||||
&operation.RunContainerOperation{MachineID: "m-1", MachineName: "machine-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "plan has RunContainerOperation",
|
||||
plan: ServicePlan{
|
||||
ServiceID: "svc-1",
|
||||
ServiceName: "app",
|
||||
Spec: api.ServiceSpec{PreDeploy: hook},
|
||||
SequenceOperation: operation.SequenceOperation{
|
||||
Operations: []operation.Operation{
|
||||
&operation.RunContainerOperation{MachineID: "m-1", MachineName: "machine-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []operation.Operation{
|
||||
&operation.RunPreDeployOperation{
|
||||
ServiceID: "svc-1",
|
||||
MachineID: "m-1",
|
||||
MachineName: "machine-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plan has ReplaceContainerOperation",
|
||||
plan: ServicePlan{
|
||||
ServiceID: "svc-1",
|
||||
ServiceName: "app",
|
||||
Spec: api.ServiceSpec{PreDeploy: hook},
|
||||
SequenceOperation: operation.SequenceOperation{
|
||||
Operations: []operation.Operation{
|
||||
&operation.ReplaceContainerOperation{MachineID: "m-2", MachineName: "machine-2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []operation.Operation{
|
||||
&operation.RunPreDeployOperation{
|
||||
ServiceID: "svc-1",
|
||||
MachineID: "m-2",
|
||||
MachineName: "machine-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plan has only RemoveContainerOperation",
|
||||
plan: ServicePlan{
|
||||
ServiceID: "svc-1",
|
||||
ServiceName: "app",
|
||||
Spec: api.ServiceSpec{PreDeploy: hook},
|
||||
SequenceOperation: operation.SequenceOperation{
|
||||
Operations: []operation.Operation{
|
||||
&operation.RemoveContainerOperation{MachineID: "m-1", MachineName: "machine-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "plan has only StopContainerOperation",
|
||||
plan: ServicePlan{
|
||||
ServiceID: "svc-1",
|
||||
ServiceName: "app",
|
||||
Spec: api.ServiceSpec{PreDeploy: hook},
|
||||
SequenceOperation: operation.SequenceOperation{
|
||||
Operations: []operation.Operation{
|
||||
&operation.StopContainerOperation{MachineID: "m-1", MachineName: "machine-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "empty plan with no operations",
|
||||
plan: ServicePlan{
|
||||
ServiceID: "svc-1",
|
||||
ServiceName: "app",
|
||||
Spec: api.ServiceSpec{PreDeploy: hook},
|
||||
},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "stopped hook containers are collected for cleanup",
|
||||
plan: ServicePlan{
|
||||
ServiceID: "svc-1",
|
||||
ServiceName: "app",
|
||||
Spec: api.ServiceSpec{PreDeploy: hook},
|
||||
SequenceOperation: operation.SequenceOperation{
|
||||
Operations: []operation.Operation{
|
||||
&operation.RunContainerOperation{MachineID: "m-1", MachineName: "machine-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
svc: &api.Service{
|
||||
HookContainers: []api.MachineServiceContainer{
|
||||
{MachineID: "m-1", Container: newServiceContainer("old-hook-1", container.State{Status: "exited"})},
|
||||
},
|
||||
},
|
||||
expected: []operation.Operation{
|
||||
&operation.RunPreDeployOperation{
|
||||
ServiceID: "svc-1",
|
||||
MachineID: "m-1",
|
||||
MachineName: "machine-1",
|
||||
OldContainerIDs: []string{"old-hook-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "running hook containers are stopped before run",
|
||||
plan: ServicePlan{
|
||||
ServiceID: "svc-1",
|
||||
ServiceName: "app",
|
||||
Spec: api.ServiceSpec{PreDeploy: hook},
|
||||
SequenceOperation: operation.SequenceOperation{
|
||||
Operations: []operation.Operation{
|
||||
&operation.RunContainerOperation{MachineID: "m-1", MachineName: "machine-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
svc: &api.Service{
|
||||
HookContainers: []api.MachineServiceContainer{
|
||||
{MachineID: "m-2", Container: runningHook1},
|
||||
},
|
||||
},
|
||||
expected: []operation.Operation{
|
||||
&operation.StopPreDeployOperation{
|
||||
MachineID: "m-2",
|
||||
MachineName: "machine-2",
|
||||
Container: runningHook1,
|
||||
},
|
||||
&operation.RunPreDeployOperation{
|
||||
ServiceID: "svc-1",
|
||||
MachineID: "m-1",
|
||||
MachineName: "machine-1",
|
||||
OldContainerIDs: []string{"running-hook-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed stopped and running hook containers and mixed container operations",
|
||||
plan: ServicePlan{
|
||||
ServiceID: "svc-1",
|
||||
ServiceName: "app",
|
||||
Spec: api.ServiceSpec{PreDeploy: hook},
|
||||
SequenceOperation: operation.SequenceOperation{
|
||||
Operations: []operation.Operation{
|
||||
&operation.StopContainerOperation{MachineID: "m-1", MachineName: "machine-1"},
|
||||
&operation.RemoveContainerOperation{MachineID: "m-1", MachineName: "machine-1"},
|
||||
&operation.ReplaceContainerOperation{MachineID: "m-2", MachineName: "machine-2"},
|
||||
&operation.RunContainerOperation{MachineID: "m-3", MachineName: "machine-3"},
|
||||
&operation.RemoveContainerOperation{MachineID: "m-3", MachineName: "machine-3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
svc: &api.Service{
|
||||
HookContainers: []api.MachineServiceContainer{
|
||||
{MachineID: "m-1", Container: newServiceContainer("stopped-hook", container.State{Status: "exited"})},
|
||||
{MachineID: "m-1", Container: runningHook1},
|
||||
{MachineID: "m-3", Container: runningHook2},
|
||||
},
|
||||
},
|
||||
expected: []operation.Operation{
|
||||
&operation.StopPreDeployOperation{
|
||||
MachineID: "m-1",
|
||||
MachineName: "machine-1",
|
||||
Container: runningHook1,
|
||||
},
|
||||
&operation.StopPreDeployOperation{
|
||||
MachineID: "m-3",
|
||||
MachineName: "machine-3",
|
||||
Container: runningHook2,
|
||||
},
|
||||
&operation.RunPreDeployOperation{
|
||||
ServiceID: "svc-1",
|
||||
MachineID: "m-2",
|
||||
MachineName: "machine-2",
|
||||
OldContainerIDs: []string{"stopped-hook", "running-hook-1", "running-hook-2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := strategy.preDeployOperations(tt.svc, tt.plan)
|
||||
if tt.expected == nil {
|
||||
assert.Nil(t, result)
|
||||
return
|
||||
}
|
||||
assertOperationsEqual(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newServiceContainer creates an api.ServiceContainer with the given ID and state.
|
||||
func newServiceContainer(id string, state container.State) api.ServiceContainer {
|
||||
return api.ServiceContainer{Container: api.Container{
|
||||
InspectResponse: container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
ID: id,
|
||||
State: &state,
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -430,6 +671,7 @@ func assertOperationsEqual(t *testing.T, expected, actual []operation.Operation)
|
||||
opts := cmp.Options{
|
||||
cmpopts.IgnoreFields(operation.RunContainerOperation{}, "Spec"),
|
||||
cmpopts.IgnoreFields(operation.ReplaceContainerOperation{}, "Spec"),
|
||||
cmpopts.IgnoreFields(operation.RunPreDeployOperation{}, "Spec"),
|
||||
cmpopts.IgnoreUnexported(api.Container{}),
|
||||
}
|
||||
if diff := cmp.Diff(expected, actual, opts); diff != "" {
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
cliprogress "github.com/psviderski/uncloud/internal/cli/progress"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/caddyconfig"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
@@ -148,7 +149,7 @@ func verifyCaddyReachable(ctx context.Context, m *pb.MachineInfo) error {
|
||||
publicIP, _ := m.PublicIp.ToAddr()
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Machine %s (%s)", m.Name, publicIP)
|
||||
eventID := cliprogress.MachineEventID(m.Name, publicIP.String())
|
||||
pw.Event(progress.NewEvent(eventID, progress.Working, "Querying"))
|
||||
|
||||
verifyURL := getVerifyURL(publicIP)
|
||||
|
||||
@@ -214,7 +214,7 @@ func (m *LogMerger) run() {
|
||||
|
||||
for _, s := range stalled {
|
||||
errEntry := api.ServiceLogEntry{
|
||||
ContainerLogEntry: api.ContainerLogEntry{
|
||||
LogEntry: api.LogEntry{
|
||||
Err: api.ErrLogStreamStalled,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// testEntry creates a ServiceLogEntry for testing.
|
||||
func testEntry(stream api.LogStreamType, ts time.Time, msg string) api.ServiceLogEntry {
|
||||
return api.ServiceLogEntry{
|
||||
ContainerLogEntry: api.ContainerLogEntry{
|
||||
LogEntry: api.LogEntry{
|
||||
Stream: stream,
|
||||
Timestamp: ts,
|
||||
Message: []byte(msg),
|
||||
@@ -91,7 +91,7 @@ func TestLogMerger_PreservesData(t *testing.T) {
|
||||
|
||||
e := api.ServiceLogEntry{
|
||||
Metadata: metadata,
|
||||
ContainerLogEntry: api.ContainerLogEntry{
|
||||
LogEntry: api.LogEntry{
|
||||
Stream: api.LogStreamStdout,
|
||||
Timestamp: time.Now(),
|
||||
Message: []byte("test"),
|
||||
@@ -199,7 +199,7 @@ func TestLogMerger_ErrorForwarding(t *testing.T) {
|
||||
ch1 <- testEntry(api.LogStreamStdout, t1, "ch1-first")
|
||||
// Send an error entry.
|
||||
ch1 <- api.ServiceLogEntry{
|
||||
ContainerLogEntry: api.ContainerLogEntry{
|
||||
LogEntry: api.LogEntry{
|
||||
Err: assert.AnError,
|
||||
},
|
||||
}
|
||||
|
||||
+17
-16
@@ -24,7 +24,8 @@ func (cli *Client) ServiceLogs(
|
||||
return svc, nil, fmt.Errorf("inspect service: %w", err)
|
||||
}
|
||||
|
||||
if len(svc.Containers) == 0 {
|
||||
allContainers := append(svc.Containers, svc.HookContainers...)
|
||||
if len(allContainers) == 0 {
|
||||
return svc, nil, fmt.Errorf("no containers found for service: %s", serviceNameOrID)
|
||||
}
|
||||
|
||||
@@ -35,8 +36,8 @@ func (cli *Client) ServiceLogs(
|
||||
return svc, nil, fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
|
||||
ctrStreams := make([]<-chan api.ServiceLogEntry, 0, len(svc.Containers))
|
||||
for _, ctr := range svc.Containers {
|
||||
ctrStreams := make([]<-chan api.ServiceLogEntry, 0, len(allContainers))
|
||||
for _, ctr := range allContainers {
|
||||
// Skip containers not running on the specified machines.
|
||||
m := machines.FindByNameOrID(ctr.MachineID)
|
||||
if len(opts.Machines) > 0 && m == nil {
|
||||
@@ -81,18 +82,18 @@ func (cli *Client) ServiceLogs(
|
||||
// ContainerLogs streams log entries from a single container on a specified machine.
|
||||
func (cli *Client) ContainerLogs(
|
||||
ctx context.Context, machineNameOrID string, containerID string, opts api.ServiceLogsOptions,
|
||||
) (<-chan api.ContainerLogEntry, error) {
|
||||
) (<-chan api.LogEntry, error) {
|
||||
proxyCtx, _, err := cli.ProxyMachinesContext(ctx, []string{machineNameOrID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request context to proxy to machine '%s': %w", machineNameOrID, err)
|
||||
}
|
||||
|
||||
req := &pb.ContainerLogsRequest{
|
||||
ContainerId: containerID,
|
||||
Follow: opts.Follow,
|
||||
Tail: int32(opts.Tail),
|
||||
Since: opts.Since,
|
||||
Until: opts.Until,
|
||||
req := &pb.LogsRequest{
|
||||
Id: containerID,
|
||||
Follow: opts.Follow,
|
||||
Tail: int32(opts.Tail),
|
||||
Since: opts.Since,
|
||||
Until: opts.Until,
|
||||
}
|
||||
if !opts.Follow && opts.Tail == 0 {
|
||||
// If not following and tail is 0, set tail to -1 to return all logs.
|
||||
@@ -105,7 +106,7 @@ func (cli *Client) ContainerLogs(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan api.ContainerLogEntry)
|
||||
ch := make(chan api.LogEntry)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
@@ -116,13 +117,13 @@ func (cli *Client) ContainerLogs(
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ch <- api.ContainerLogEntry{
|
||||
ch <- api.LogEntry{
|
||||
Err: err,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
entry := api.ContainerLogEntry{
|
||||
entry := api.LogEntry{
|
||||
Stream: api.LogStreamTypeFromProto(pbEntry.Stream),
|
||||
Message: pbEntry.Message,
|
||||
Timestamp: pbEntry.Timestamp.AsTime(),
|
||||
@@ -141,15 +142,15 @@ func (cli *Client) ContainerLogs(
|
||||
|
||||
// logsStreamWithServiceMetadata wraps a container logs stream and enriches each log entry with service metadata.
|
||||
func logsStreamWithServiceMetadata(
|
||||
stream <-chan api.ContainerLogEntry, metadata api.ServiceLogEntryMetadata,
|
||||
stream <-chan api.LogEntry, metadata api.ServiceLogEntryMetadata,
|
||||
) <-chan api.ServiceLogEntry {
|
||||
out := make(chan api.ServiceLogEntry)
|
||||
|
||||
go func() {
|
||||
for entry := range stream {
|
||||
out <- api.ServiceLogEntry{
|
||||
Metadata: metadata,
|
||||
ContainerLogEntry: entry,
|
||||
Metadata: metadata,
|
||||
LogEntry: entry,
|
||||
}
|
||||
}
|
||||
close(out)
|
||||
|
||||
+28
-21
@@ -109,7 +109,7 @@ func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Ser
|
||||
}
|
||||
listCtx := metadata.NewOutgoingContext(ctx, md)
|
||||
|
||||
// List all service containers including stopped ones.
|
||||
// List all service containers including stopped ones and deployment hooks.
|
||||
opts := container.ListOptions{All: true}
|
||||
machineContainers, err := cli.Docker.ListServiceContainers(listCtx, nameOrID, opts)
|
||||
if err != nil {
|
||||
@@ -146,16 +146,15 @@ func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Ser
|
||||
}
|
||||
}
|
||||
|
||||
for _, ctr := range mc.Containers {
|
||||
if ctr.ServiceID() == nameOrID || ctr.ServiceName() == nameOrID {
|
||||
containers = append(containers, api.MachineServiceContainer{
|
||||
MachineID: machineID,
|
||||
Container: ctr,
|
||||
})
|
||||
// Collect both regular and hook containers for the service.
|
||||
for _, ctr := range append(mc.Containers, mc.HookContainers...) {
|
||||
containers = append(containers, api.MachineServiceContainer{
|
||||
MachineID: machineID,
|
||||
Container: ctr,
|
||||
})
|
||||
|
||||
if ctr.ServiceID() == nameOrID {
|
||||
foundByID = true
|
||||
}
|
||||
if ctr.ServiceID() == nameOrID {
|
||||
foundByID = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,14 +180,22 @@ func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Ser
|
||||
}
|
||||
}
|
||||
|
||||
svc = api.Service{
|
||||
ID: containers[0].Container.ServiceID(),
|
||||
Name: containers[0].Container.ServiceName(),
|
||||
Mode: containers[0].Container.ServiceMode(),
|
||||
Containers: containers,
|
||||
// Partition containers into regular service containers and hook containers.
|
||||
var serviceContainers, hookContainers []api.MachineServiceContainer
|
||||
for _, mc := range containers {
|
||||
if mc.Container.IsHook() {
|
||||
hookContainers = append(hookContainers, mc)
|
||||
} else {
|
||||
serviceContainers = append(serviceContainers, mc)
|
||||
}
|
||||
}
|
||||
if svc.Mode == "" {
|
||||
svc.Mode = api.ServiceModeReplicated
|
||||
|
||||
svc = api.Service{
|
||||
ID: containers[0].Container.ServiceID(),
|
||||
Name: containers[0].Container.ServiceName(),
|
||||
Mode: containers[0].Container.ServiceMode(),
|
||||
Containers: serviceContainers,
|
||||
HookContainers: hookContainers,
|
||||
}
|
||||
|
||||
return svc, nil
|
||||
@@ -239,7 +246,7 @@ func (cli *Client) RemoveService(ctx context.Context, id string) error {
|
||||
errCh := make(chan error)
|
||||
|
||||
// Remove all containers on all machines that belong to the service.
|
||||
for _, mc := range svc.Containers {
|
||||
for _, mc := range append(svc.Containers, svc.HookContainers...) {
|
||||
wg.Go(func() {
|
||||
err := cli.StopContainer(ctx, svc.ID, mc.Container.ID, container.StopOptions{})
|
||||
if err != nil {
|
||||
@@ -280,8 +287,8 @@ func (cli *Client) StopService(ctx context.Context, id string, opts container.St
|
||||
wg := sync.WaitGroup{}
|
||||
errCh := make(chan error)
|
||||
|
||||
// Stop all containers on all machines that belong to the service.
|
||||
for _, mc := range svc.Containers {
|
||||
// Stop all containers on all machines that belong to the service, including hook containers.
|
||||
for _, mc := range append(svc.Containers, svc.HookContainers...) {
|
||||
wg.Go(func() {
|
||||
err := cli.StopContainer(ctx, svc.ID, mc.Container.ID, opts)
|
||||
if err != nil {
|
||||
@@ -371,7 +378,7 @@ func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ctr := range mc.Containers {
|
||||
for _, ctr := range append(mc.Containers, mc.HookContainers...) {
|
||||
if _, ok := servicesByID[ctr.ServiceID()]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
cliprogress "github.com/psviderski/uncloud/internal/cli/progress"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
@@ -30,7 +31,7 @@ func (cli *Client) CreateVolume(
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Volume %s on %s", opts.Name, machine.Machine.Name)
|
||||
eventID := cliprogress.VolumeEventID(opts.Name, machine.Machine.Name)
|
||||
pw.Event(progress.CreatingEvent(eventID))
|
||||
|
||||
vol, err := cli.Docker.CreateVolume(ctx, opts)
|
||||
@@ -120,7 +121,7 @@ func (cli *Client) RemoveVolume(ctx context.Context, machineNameOrID, volumeName
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Volume %s on %s", volumeName, machine.Machine.Name)
|
||||
eventID := cliprogress.VolumeEventID(volumeName, machine.Machine.Name)
|
||||
pw.Event(progress.RemovingEvent(eventID))
|
||||
|
||||
if err = cli.Docker.RemoveVolume(ctx, volumeName, force); err != nil {
|
||||
|
||||
@@ -25,7 +25,7 @@ trap cleanup INT TERM EXIT
|
||||
|
||||
dind dockerd &
|
||||
echo "Waiting for Docker in Docker to be ready..."
|
||||
timeout 5s sh -c "until docker info &> /dev/null; do sleep 0.1; done"
|
||||
timeout 60s sh -c "until docker info &> /dev/null; do sleep 0.5; done"
|
||||
echo "Docker in Docker is ready."
|
||||
|
||||
echo "Loading corrosion image from /images/corrosion.tar..."
|
||||
|
||||
@@ -31,6 +31,10 @@ func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpe
|
||||
for _, mc := range svc.Containers {
|
||||
assertContainerMatchesSpec(t, mc.Container, spec)
|
||||
}
|
||||
|
||||
if spec.PreDeploy != nil {
|
||||
assertHookContainersMatchSpec(t, svc, spec)
|
||||
}
|
||||
}
|
||||
|
||||
func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) {
|
||||
@@ -139,6 +143,78 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api
|
||||
assert.Contains(t, ctr.NetworkSettings.Networks, machinedocker.NetworkName)
|
||||
}
|
||||
|
||||
// assertHookContainersMatchSpec validates that hook containers in the service match the pre-deploy hook spec.
|
||||
func assertHookContainersMatchSpec(t *testing.T, svc api.Service, spec api.ServiceSpec) {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, svc.HookContainers, "Expected at least one hook container")
|
||||
|
||||
for _, mc := range svc.HookContainers {
|
||||
ctr := mc.Container
|
||||
|
||||
// Verify labels.
|
||||
assert.True(t, api.ValidateServiceID(ctr.Config.Labels[api.LabelServiceID]))
|
||||
assert.Equal(t, spec.Name, ctr.Config.Labels[api.LabelServiceName])
|
||||
assert.Equal(t, api.LabelHookPreDeploy, ctr.Config.Labels[api.LabelHook])
|
||||
assert.Contains(t, ctr.Config.Labels, api.LabelManaged)
|
||||
assert.NotContains(t, ctr.Config.Labels, api.LabelServiceMode,
|
||||
"Hook containers should not have the service mode label")
|
||||
|
||||
assert.EqualValues(t, spec.PreDeploy.Command, ctr.Config.Cmd)
|
||||
if spec.Container.Entrypoint != nil {
|
||||
assert.EqualValues(t, spec.Container.Entrypoint, ctr.Config.Entrypoint)
|
||||
}
|
||||
|
||||
// Service env vars are inherited by hook containers.
|
||||
for _, env := range spec.Container.Env.ToSlice() {
|
||||
assert.Contains(t, ctr.Config.Env, env)
|
||||
}
|
||||
// Hook-specific env vars.
|
||||
for _, env := range spec.PreDeploy.Env.ToSlice() {
|
||||
assert.Contains(t, ctr.Config.Env, env)
|
||||
}
|
||||
assert.Contains(t, ctr.Config.Env, "UNCLOUD_HOOK_PRE_DEPLOY=true")
|
||||
|
||||
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+"-pre-deploy-"),
|
||||
"Hook container name %q should start with %q", ctr.Name, spec.Name+"-pre-deploy-")
|
||||
|
||||
// Privileged is overridden by PreDeploy.Privileged if set, otherwise inherited from the service.
|
||||
if spec.PreDeploy.Privileged != nil {
|
||||
assert.Equal(t, *spec.PreDeploy.Privileged, ctr.HostConfig.Privileged)
|
||||
} else {
|
||||
assert.Equal(t, spec.Container.Privileged, ctr.HostConfig.Privileged)
|
||||
}
|
||||
|
||||
// User is overridden by PreDeploy.User if set.
|
||||
if spec.PreDeploy.User != "" {
|
||||
assert.Equal(t, spec.PreDeploy.User, ctr.Config.User)
|
||||
} else if spec.Container.User != "" {
|
||||
assert.Equal(t, spec.Container.User, ctr.Config.User)
|
||||
}
|
||||
|
||||
// Compute resources.
|
||||
assert.Equal(t, spec.Container.Resources.CPU, ctr.HostConfig.Resources.NanoCPUs)
|
||||
assert.Equal(t, spec.Container.Resources.Memory, ctr.HostConfig.Resources.Memory)
|
||||
assert.Equal(t, spec.Container.Resources.MemoryReservation, ctr.HostConfig.Resources.MemoryReservation)
|
||||
|
||||
// Hook-specific overrides: disabled restart, disabled healthcheck, no ports.
|
||||
assert.Equal(t, container.RestartPolicy{Name: container.RestartPolicyDisabled}, ctr.HostConfig.RestartPolicy)
|
||||
require.NotNil(t, ctr.Config.Healthcheck)
|
||||
assert.Equal(t, []string{"NONE"}, ctr.Config.Healthcheck.Test)
|
||||
assert.Empty(t, ctr.HostConfig.PortBindings)
|
||||
|
||||
assert.False(t, ctr.State.Running, "Hook container should not be running")
|
||||
assert.Equal(t, 0, ctr.State.ExitCode, "Hook container should exit with code 0")
|
||||
|
||||
assertContainerMountsMatchSpec(t, ctr.HostConfig.Mounts, spec)
|
||||
|
||||
// Verify network settings.
|
||||
assert.Len(t, ctr.NetworkSettings.Networks, 1)
|
||||
assert.Contains(t, ctr.NetworkSettings.Networks, machinedocker.NetworkName)
|
||||
}
|
||||
}
|
||||
|
||||
func assertContainerMountsMatchSpec(t *testing.T, mounts []mount.Mount, spec api.ServiceSpec) {
|
||||
expectedMounts, err := machinedocker.ToDockerMounts(spec.Volumes, spec.Container.VolumeMounts)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -55,7 +55,7 @@ func createTestCluster(
|
||||
})
|
||||
|
||||
if waitReady {
|
||||
require.NoError(t, p.WaitClusterReady(ctx, c, 60*time.Second))
|
||||
require.NoError(t, p.WaitClusterReady(ctx, c, 90*time.Second))
|
||||
}
|
||||
|
||||
return c, p
|
||||
@@ -104,7 +104,7 @@ func TestClusterLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
return true
|
||||
}, 15*time.Second, 50*time.Millisecond, "cluster store not reconciled on machine #%d", i+1)
|
||||
}, 30*time.Second, 50*time.Millisecond, "cluster store not reconciled on machine #%d", i+1)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -601,4 +601,60 @@ volumes:
|
||||
assert.ElementsMatch(t, machines.ToSlice(), expectedMachines,
|
||||
"Containers should be distributed across all machines")
|
||||
})
|
||||
|
||||
t.Run("pre-deploy hook", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
name := "test-compose-predeploy"
|
||||
volumeName := "test-compose-predeploy-data"
|
||||
t.Cleanup(func() {
|
||||
removeServices(t, cli, name)
|
||||
removeVolumes(t, cli, volumeName)
|
||||
})
|
||||
|
||||
project, err := compose.LoadProject(ctx, []string{"fixtures/compose-predeploy.yaml"})
|
||||
require.NoError(t, err)
|
||||
|
||||
deployment, err := compose.NewDeployment(ctx, cli, project)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = deployment.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := cli.InspectService(ctx, name)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertServiceMatchesSpec(t, svc, api.ServiceSpec{
|
||||
Name: name,
|
||||
Container: api.ContainerSpec{
|
||||
Image: "busybox:1.37.0-uclibc",
|
||||
Command: []string{"sleep", "600"},
|
||||
Env: api.EnvVars{"SERVICE_VAR": "from-service"},
|
||||
VolumeMounts: []api.VolumeMount{
|
||||
{
|
||||
VolumeName: volumeName,
|
||||
ContainerPath: "/data",
|
||||
},
|
||||
},
|
||||
},
|
||||
Volumes: []api.VolumeSpec{
|
||||
{
|
||||
Name: volumeName,
|
||||
Type: api.VolumeTypeVolume,
|
||||
},
|
||||
},
|
||||
PreDeploy: &api.PreDeployHook{
|
||||
Command: []string{"sh", "-c", "echo hello-from-predeploy > /data/predeploy.txt"},
|
||||
Env: api.EnvVars{"HOOK_VAR": "from-hook"},
|
||||
User: "root",
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the pre-deploy hook wrote to the shared volume by reading the file from the running container.
|
||||
containerID := svc.Containers[0].Container.ID
|
||||
output, err := execInContainerAndReadOutput(t, ctx, cli, name, containerID,
|
||||
[]string{"cat", "/data/predeploy.txt"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello-from-predeploy\n", output)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
test-compose-predeploy:
|
||||
image: busybox:1.37.0-uclibc
|
||||
command: ["sleep", "600"]
|
||||
environment:
|
||||
SERVICE_VAR: from-service
|
||||
volumes:
|
||||
- test-compose-predeploy-data:/data
|
||||
x-pre_deploy:
|
||||
command: [sh, -c, echo hello-from-predeploy > /data/predeploy.txt]
|
||||
environment:
|
||||
HOOK_VAR: from-hook
|
||||
user: root
|
||||
timeout: 30s
|
||||
|
||||
volumes:
|
||||
test-compose-predeploy-data:
|
||||
+15
-10
@@ -40,14 +40,18 @@ func TestDeployment(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 3}, true)
|
||||
|
||||
cli, err := c.Machines[0].Connect(ctx)
|
||||
require.NoError(t, err)
|
||||
cli, cErr := c.Machines[0].Connect(ctx)
|
||||
require.NoError(t, cErr)
|
||||
|
||||
t.Run("global auto-generated name", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
name := "" // auto-generated and updated
|
||||
t.Cleanup(func() {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
|
||||
err := cli.RemoveService(ctx, name)
|
||||
if !errors.Is(err, api.ErrNotFound) {
|
||||
require.NoError(t, err)
|
||||
@@ -70,7 +74,7 @@ func TestDeployment(t *testing.T) {
|
||||
}
|
||||
deployment := cli.NewDeployment(spec, nil)
|
||||
|
||||
err = deployment.Validate(ctx)
|
||||
err := deployment.Validate(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
plan, err := deployment.Plan(ctx)
|
||||
@@ -236,7 +240,7 @@ func TestDeployment(t *testing.T) {
|
||||
}
|
||||
deployment := cli.NewDeployment(spec, nil)
|
||||
|
||||
_, err = deployment.Run(ctx)
|
||||
_, err := deployment.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := cli.InspectService(ctx, name)
|
||||
@@ -542,7 +546,7 @@ myapp.example.com {
|
||||
}
|
||||
deployment := cli.NewDeployment(spec, nil)
|
||||
|
||||
err = deployment.Validate(ctx)
|
||||
err := deployment.Validate(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
plan, err := deployment.Plan(ctx)
|
||||
@@ -709,7 +713,7 @@ myapp.example.com {
|
||||
|
||||
deployment := cli.NewDeployment(spec, nil)
|
||||
|
||||
_, err = deployment.Run(ctx)
|
||||
_, err := deployment.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify service has 2 containers on machines 0 and 1.
|
||||
@@ -1090,7 +1094,7 @@ myapp.example.com {
|
||||
}
|
||||
|
||||
d := deploy.NewDeployment(cli, spec, nil)
|
||||
_, err = d.Run(ctx)
|
||||
_, err := d.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := cli.InspectService(ctx, serviceName)
|
||||
@@ -1128,7 +1132,7 @@ myapp.example.com {
|
||||
}
|
||||
|
||||
d := deploy.NewDeployment(cli, spec, nil)
|
||||
_, err = d.Run(ctx)
|
||||
_, err := d.Run(ctx)
|
||||
require.Error(t, err, "Global deployment should fail when required volume doesn't exist")
|
||||
require.Contains(t, err.Error(), "no machines available")
|
||||
})
|
||||
@@ -1270,7 +1274,7 @@ myapp.example.com {
|
||||
}
|
||||
|
||||
deployment := cli.NewDeployment(spec, nil)
|
||||
_, err = deployment.Run(ctx)
|
||||
_, err := deployment.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := cli.InspectService(ctx, serviceName)
|
||||
@@ -2086,7 +2090,8 @@ func TestServiceLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
assertNoDNSErrors := func(t *testing.T, dnsOutput string) {
|
||||
assert.NotContains(t, dnsOutput, "server can't find", "DNS query should not contain NXDOMAIN/SERVFAIL errors")
|
||||
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) {
|
||||
|
||||
@@ -446,6 +446,6 @@ Note: Docker installation was preserved. If you want to completely remove Docker
|
||||
## Further reading
|
||||
|
||||
- **[Add more machines](../9-cli-reference/uc_machine_add.md)**: Scale horizontally by creating a cluster of machines
|
||||
- **[Ingress & HTTP](../3-concepts/1-ingress/1-overview.md)**: Learn how Uncloud handles incoming traffic and how to
|
||||
- **[Ingress & HTTP](../3-concepts/2-ingress/1-overview.md)**: Learn how Uncloud handles incoming traffic and how to
|
||||
expose your services to the internet
|
||||
- **[CLI reference](../9-cli-reference/uc.md)**: Explore all available commands and options
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Connecting to a cluster
|
||||
|
||||
`uc` only needs to reach one machine to work with the entire cluster. That machine acts as an **entry point** and
|
||||
forwards requests to other machines as needed.
|
||||
|
||||
`uc` stores **cluster contexts** and **connection details** in a [configuration file](../../7-cli-config-reference.md)
|
||||
(default location is `~/.config/uncloud/config.yaml`).
|
||||
|
||||
When you initialise a new cluster with `uc machine init` or add a machine to an existing cluster with `uc machine add`,
|
||||
they automatically save the SSH addresses of your machines to the config so you don't have to specify them every time.
|
||||
|
||||
## Cluster contexts
|
||||
|
||||
The [config file](../../7-cli-config-reference.md) organises connections into **contexts**. Each context represents a
|
||||
cluster. It has a name and a list of connection details for the machines in that cluster.
|
||||
|
||||
A context is not the same thing as a cluster. It is your local view of a cluster: which machines you can connect through
|
||||
and in what order to try them. Different people or environments may need to reach the same cluster in different ways.
|
||||
|
||||
You can also manually create multiple contexts for the same cluster. For example, one that connects through
|
||||
a machine with a public IP when you're not in the office, and another that connects through a private machine on the
|
||||
office network when you're on-site to reduce latency. You can switch between them depending on where you are.
|
||||
|
||||
### Managing contexts
|
||||
|
||||
Use these commands to manage the contexts in your config:
|
||||
|
||||
- [`uc ctx`](../../9-cli-reference/uc_ctx.md): Switch contexts using an interactive TUI
|
||||
- [`uc ctx ls`](../../9-cli-reference/uc_ctx_ls.md): List all contexts and see which one is current
|
||||
- [`uc ctx use`](../../9-cli-reference/uc_ctx_use.md): Switch the current context by name
|
||||
- [`uc ctx conn`](../../9-cli-reference/uc_ctx_connection.md): Change the default connection for the current context
|
||||
using an interactive TUI
|
||||
|
||||
You can also set `x-context` in your Compose file to pin a specific context for deployments. See
|
||||
[Deploy to a specific cluster context](../../4-guides/1-deployments/1-deploy-app.md#deploy-to-a-specific-cluster-context)
|
||||
for details.
|
||||
|
||||
## Connection resolution
|
||||
|
||||
When you run a `uc` command, it determines which cluster to connect to using this priority:
|
||||
|
||||
1. If `--connect` is set, `uc` connects directly to that machine and ignores the config file entirely.
|
||||
2. If `--context` is set, `uc` uses that context from the config.
|
||||
3. Otherwise, `uc` uses `current_context` from the config.
|
||||
|
||||
Once the context is resolved, `uc` tries each connection in the context's `connections` list in order until one
|
||||
succeeds.
|
||||
|
||||
## Global flags and environment variables
|
||||
|
||||
These flags are available on every `uc` command. They can also be set with an environment variable. The flag takes
|
||||
priority if both are set.
|
||||
|
||||
| Flag | Environment variable | Description |
|
||||
|--------------------|----------------------|-------------------------------------------------------------------|
|
||||
| `--uncloud-config` | `UNCLOUD_CONFIG` | Path to the config file |
|
||||
| `--context` | `UNCLOUD_CONTEXT` | Use a specific context instead of `current_context` in the config |
|
||||
| `--connect` | `UNCLOUD_CONNECT` | Bypass the config file and connect directly |
|
||||
|
||||
### Connecting directly without a config
|
||||
|
||||
The `--connect` flag or `UNCLOUD_CONNECT` environment variable let you run one-off commands against a cluster without
|
||||
using a config. This is useful for CI pipelines and scripts where you don't want to set up a config file.
|
||||
|
||||
It accepts these formats:
|
||||
|
||||
```shell
|
||||
# System 'ssh' command with full SSH config support
|
||||
uc --connect root@203.0.113.1 ls
|
||||
|
||||
# System 'ssh' command (explicit scheme, same as above)
|
||||
uc --connect ssh://root@203.0.113.1 ls
|
||||
|
||||
# Go's built-in SSH library (no SSH config support, useful when the system ssh is not available)
|
||||
uc --connect ssh+go://root@203.0.113.1 ls
|
||||
|
||||
# Direct connection to machine gRPC API over TCP (for advanced users with custom setups)
|
||||
uc --connect tcp://[fdcc:4439:f545:3ca:5d17:66e5:7c96:40bd]:51000 ls
|
||||
|
||||
# Direct connection to machine gRPC API over a Unix socket (for running uc locally on a cluster machine)
|
||||
uc --connect unix:///run/uncloud/uncloud.sock ls
|
||||
```
|
||||
|
||||
:::info
|
||||
|
||||
Don't use `--connect` with `uc machine init`. `--connect` is for specifying or overriding the connection to an existing
|
||||
cluster, but `uc machine init` creates a new one and writes the new cluster context to the config file. You can discard
|
||||
the config when initialising a cluster with `--uncloud-config /dev/null` if you don't want to save it.
|
||||
|
||||
:::
|
||||
@@ -0,0 +1,4 @@
|
||||
label: Clusters
|
||||
collapsed: true # keep the category closed by default
|
||||
link:
|
||||
type: generated-index
|
||||
@@ -141,7 +141,7 @@ This generates tags like:
|
||||
`uc deploy` renders the image templates when it loads the Compose file and then uses the resulting names for the build
|
||||
and deploy stages.
|
||||
|
||||
See the [Image tag template](../../8-compose-file-reference/2-image-tag-template.md) reference for all available
|
||||
See the [Image tag template](../../8-compose-file-reference/3-image-tag-template.md) reference for all available
|
||||
template variables and functions.
|
||||
|
||||
### Separate build and deploy steps
|
||||
@@ -338,7 +338,7 @@ services:
|
||||
With this configuration, `uc deploy` and other commands using the Compose file will always target the `prod` context,
|
||||
regardless of your currently active context. You can still override it with the `--context` flag if needed.
|
||||
|
||||
See [`x-context`](../../8-compose-file-reference/1-support-matrix.md#x-context) for more details.
|
||||
See [`x-context`](../../8-compose-file-reference/2-extensions.md#x-context) for more details.
|
||||
|
||||
## Use a different Compose file location
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Deploy to specific machines
|
||||
|
||||
Deploy services to specific machines in your cluster using the
|
||||
[`x-machines`](../../8-compose-file-reference/1-support-matrix.md#x-machines) extension in your Compose file.
|
||||
[`x-machines`](../../8-compose-file-reference/2-extensions.md#x-machines) extension in your Compose file.
|
||||
|
||||
## When to target specific machines
|
||||
|
||||
By default, Uncloud randomly chooses available machines to run your services on, evenly spreading multiple replicas of a
|
||||
service across all machines for high availability. You can restrict which machines can run your service using the
|
||||
[`x-machines`](../../8-compose-file-reference/1-support-matrix.md#x-machines) extension in your Compose file.
|
||||
[`x-machines`](../../8-compose-file-reference/2-extensions.md#x-machines) extension in your Compose file.
|
||||
|
||||
This is useful when you want to:
|
||||
|
||||
@@ -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): Build and deploy from source code or prebuilt images
|
||||
- [Deploy an app](1-deploy-app.md): Build and deploy from source code or pre-built 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
|
||||
|
||||
@@ -33,7 +33,7 @@ Uncloud doesn't automatically scale global services to new machines.
|
||||
|
||||
## Deploy to a subset of machines
|
||||
|
||||
You can combine the `global` mode with [`x-machines`](../../8-compose-file-reference/1-support-matrix.md#x-machines)
|
||||
You can combine the `global` mode with [`x-machines`](../../8-compose-file-reference/2-extensions.md#x-machines)
|
||||
to deploy one container to each specified machine:
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
@@ -63,7 +63,7 @@ The default mode is `replicated`, where you specify the number of replicas.
|
||||
|
||||
## See also
|
||||
|
||||
- [Deploy an app](1-deploy-app.md): Deploy from source code or prebuilt images
|
||||
- [Deploy an app](1-deploy-app.md): Deploy from source code or pre-built images
|
||||
- [Deploy to specific machines](2-deploy-specific-machines.md): Deploy services to specific machines in your cluster
|
||||
- [Compose Specification: deploy.mode](https://github.com/compose-spec/compose-spec/blob/main/deploy.md#mode):
|
||||
Compose specification for deployment modes
|
||||
|
||||
@@ -131,7 +131,7 @@ services:
|
||||
:::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
|
||||
[Caddy](../../3-concepts/2-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
|
||||
@@ -168,6 +168,7 @@ configuration hasn't changed and only redeploy the remaining ones.
|
||||
|
||||
## See also
|
||||
|
||||
- [Pre-deploy hooks](5-pre-deploy-hooks.md): Run a command before deploying service containers
|
||||
- [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
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# Pre-deploy hooks
|
||||
|
||||
Run a one-off command before deploying a service.
|
||||
|
||||
Pre-deploy hooks are useful for **one-off tasks** such as:
|
||||
|
||||
- Database schema and data migrations
|
||||
- Uploading static assets to a CDN
|
||||
- Cache invalidation
|
||||
- Any setup task that needs to run **once** before new code goes live, not on every container startup
|
||||
|
||||
## How it works
|
||||
|
||||
When you run `uc deploy` for a service with a pre-deploy hook configured, the hook command runs after building and
|
||||
pushing the new image (if [building from source](1-deploy-app.md#deploy-from-source-code)) but **before** rolling out
|
||||
any new containers.
|
||||
|
||||
`uc deploy` runs your hook command inside a new container and waits for it to finish or time out (**5 minutes** by
|
||||
default). This container **inherits** most of the **service's configuration**, including the image, environment
|
||||
variables, volumes, placement, and compute resources.
|
||||
|
||||
If the command exits with code 0, the deployment continues with a normal [rolling update](4-rolling-deployments.md). If
|
||||
the command fails or times out, the deployment stops immediately with an error. `uc deploy` will display the latest logs
|
||||
from the hook container to help you diagnose the issue.
|
||||
|
||||

|
||||

|
||||
|
||||
The hook runs on one of the machines where the service will be deployed. Similar to service containers, hook containers
|
||||
can reach other services over the network, connect to databases, and read or write shared volumes. Note that file system
|
||||
changes do not persist after the hook finishes, except for changes written to shared volumes.
|
||||
|
||||
## Usage
|
||||
|
||||
Add the [`x-pre_deploy`](../../8-compose-file-reference/2-extensions.md#x-pre_deploy) extension to a service in your
|
||||
Compose file. The only required attribute is `command`, which can be a string or a list of strings, just like the
|
||||
service's [`command`](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#command).
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
x-pre_deploy:
|
||||
command: python manage.py migrate
|
||||
```
|
||||
|
||||
:::info note
|
||||
|
||||
The `command` replaces the image's default command ([`CMD`](https://docs.docker.com/reference/dockerfile/#cmd)) but the
|
||||
[`ENTRYPOINT`](https://docs.docker.com/reference/dockerfile/#entrypoint) still runs. If your image has an entrypoint,
|
||||
the hook command is passed as arguments to it. You can override the entrypoint for the service using
|
||||
[`entrypoint`](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#entrypoint) which applies to both
|
||||
hook and regular service containers.
|
||||
|
||||
:::
|
||||
|
||||
Since your command runs in the same image as the service, any tools or dependencies it needs must be installed in that
|
||||
image.
|
||||
|
||||
See [`x-pre_deploy`](../../8-compose-file-reference/2-extensions.md#x-pre_deploy) for all available attributes and
|
||||
their defaults.
|
||||
|
||||
### Database migrations
|
||||
|
||||
The most common use case for pre-deploy hooks is running database migrations before deploying a new app version:
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@db:5432/postgres
|
||||
x-ports:
|
||||
- app.example.com:8000/https
|
||||
x-pre_deploy:
|
||||
# Apply Django migrations from the built image before deploying new app containers
|
||||
command: python manage.py migrate
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
db:
|
||||
image: postgres:18
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
```
|
||||
|
||||
When you run `uc deploy`, the migration runs first inside a new container created with the same image and environment
|
||||
variables as the `web` service. Only after it succeeds, the deployment starts replacing service containers with the new
|
||||
image.
|
||||
|
||||
### Running multiple commands
|
||||
|
||||
If you need to run several tasks before deployment, you can wrap them in a single shell command:
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
x-pre_deploy:
|
||||
# Apply Django migrations, collect static files, and upload them to S3 bucket
|
||||
command: sh -c "python manage.py migrate && python manage.py collectstatic --no-input"
|
||||
```
|
||||
|
||||
For more complex scenarios, create a dedicated script and use it as the hook command:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="compose.yaml">
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
x-pre_deploy:
|
||||
command: ./scripts/pre_deploy.sh
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="scripts/pre_deploy.sh">
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Ensure the script exits immediately with a non-zero code if any command fails.
|
||||
set -e
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic --no-input
|
||||
python manage.py clear_cache
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Make sure the script is included in your service image and exits with a non-zero code on any command failure (`set -e`).
|
||||
|
||||
### Custom environment and user
|
||||
|
||||
The hook container inherits environment variables from the service. You can add hook-specific variables or override
|
||||
existing ones with `environment`. Use `user` to run the command as a different user.
|
||||
|
||||
For example, if your service runs as a non-root user but the hook needs root to fix file permissions on a shared volume:
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
user: app
|
||||
volumes:
|
||||
- data:/data
|
||||
x-pre_deploy:
|
||||
command: chown -R app:app /data/uploads
|
||||
user: root
|
||||
|
||||
volumes:
|
||||
data:
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
||||
Uncloud automatically sets `UNCLOUD_HOOK_PRE_DEPLOY=true` in the hook container. You can check this variable in a shared
|
||||
entrypoint script or your command to detect when it's running as a pre-deploy hook versus a regular service container.
|
||||
|
||||
:::
|
||||
|
||||
## Failure handling
|
||||
|
||||
### Non-zero exit code
|
||||
|
||||
When the hook command exits with a non-zero code, the deployment stops immediately. No service containers are created or
|
||||
replaced. `uc deploy` prints the latest logs from the hook container to help you diagnose the issue.
|
||||
|
||||
The failed hook container is not automatically removed, so you can inspect it with `uc inspect` and `uc ps`, and fetch
|
||||
its full logs as part of the service logs:
|
||||
|
||||
```shell
|
||||
uc logs web
|
||||
```
|
||||
|
||||
Fix the issue and run `uc deploy` again to retry.
|
||||
|
||||
### Timeout
|
||||
|
||||
If the hook doesn't finish within the timeout (default **5 minutes**), Uncloud kills the container and fails the
|
||||
deployment. The stopped container is kept for inspection, same as with a non-zero exit code.
|
||||
|
||||
You can increase the timeout for long-running tasks like large database migrations or data uploads:
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
web:
|
||||
x-pre_deploy:
|
||||
command: python manage.py migrate
|
||||
timeout: 30m
|
||||
```
|
||||
|
||||
### Idempotency
|
||||
|
||||
Design your hook commands to be **idempotent** when possible. If a deployment fails after the hook succeeds (for
|
||||
example, a new container crashes on startup) and you retry with `uc deploy`, the hook runs again.
|
||||
|
||||
Most database migration tools handle this naturally since they track which migrations have already been applied.
|
||||
|
||||
## See also
|
||||
|
||||
- [`x-pre_deploy` reference](../../8-compose-file-reference/2-extensions.md#x-pre_deploy): All available attributes
|
||||
and their defaults
|
||||
- [Rolling deployments](4-rolling-deployments.md): How Uncloud updates containers with zero downtime
|
||||
- [Deploy an app](1-deploy-app.md): Build and deploy from source code or pre-built images
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 25 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user