mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b32cd2095 | ||
|
|
425b974331 | ||
|
|
db09dec855 | ||
|
|
20cdcdf917 | ||
|
|
796363f23c | ||
|
|
cb0df2169f | ||
|
|
95afc7f289 | ||
|
|
bc65c73c51 | ||
|
|
596bc561b8 | ||
|
|
f215fae6c7 | ||
|
|
5707c37b63 | ||
|
|
5780d3a8a4 | ||
|
|
ac18ff3e8b | ||
|
|
a9a720f3c3 | ||
|
|
4b4b721d49 | ||
|
|
6373d4097c | ||
|
|
dfe12ca644 | ||
|
|
2ef8a6fd20 | ||
|
|
27cfe1c4f3 | ||
|
|
ebd4622592 | ||
|
|
4fa30ab8cb | ||
|
|
1e6aaf451e | ||
|
|
bf65fbaca5 | ||
|
|
79dc05cb66 | ||
|
|
234985b57d | ||
|
|
d89bfb8e03 | ||
|
|
91a09d5d37 | ||
|
|
e87207eae3 | ||
|
|
3c33973269 | ||
|
|
3019377310 | ||
|
|
53def3a624 | ||
|
|
eb23e956cb | ||
|
|
d029d4d0bf | ||
|
|
4433095045 | ||
|
|
05c8117f7b | ||
|
|
3fbba6d540 | ||
|
|
439d956ac2 | ||
|
|
055cfae272 | ||
|
|
76b4369aa6 | ||
|
|
74c7738bc3 | ||
|
|
f6d75d507c | ||
|
|
6a1cd7bf87 | ||
|
|
57205e1149 | ||
|
|
ce4a8cce72 | ||
|
|
75085b3929 | ||
|
|
c489fcc63c | ||
|
|
e30ea89990 | ||
|
|
9ddcbd6de0 | ||
|
|
939645e144 | ||
|
|
1d413734d8 | ||
|
|
2e655d870c | ||
|
|
eea81723b8 |
+2
-1
@@ -16,7 +16,8 @@ builds:
|
|||||||
main: ./cmd/uncloud
|
main: ./cmd/uncloud
|
||||||
binary: uncloud
|
binary: uncloud
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
# Use CGO to be able to build fsevent required by compose on macOS.
|
||||||
|
- CGO_ENABLED={{ if eq .Os "darwin" }}1{{ else }}0{{ end }}
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- linux
|
- linux
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ This document provides comprehensive information about the Uncloud project for A
|
|||||||
- **Docker Compose compatibility**: Uses familiar Docker Compose format
|
- **Docker Compose compatibility**: Uses familiar Docker Compose format
|
||||||
- **Zero-downtime deployments**: Rolling updates without service interruption
|
- **Zero-downtime deployments**: Rolling updates without service interruption
|
||||||
- **Automatic HTTPS**: Caddy reverse proxy with Let's Encrypt integration
|
- **Automatic HTTPS**: Caddy reverse proxy with Let's Encrypt integration
|
||||||
- **Managed DNS**: Free `*.cluster.uncloud.run` subdomains via Uncloud DNS service
|
- **Managed DNS**: Free `*.xxxxxx.uncld.dev` subdomains via Uncloud DNS service
|
||||||
- **Cross-machine scaling**: Run containers across multiple machines
|
- **Cross-machine scaling**: Run containers across multiple machines
|
||||||
|
|
||||||
### 🔧 Developer Experience
|
### 🔧 Developer Experience
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./website/landing/images/logo.svg" height="100" width="100" alt="Uncloud logo"/>
|
<img src="./website/landing/images/logo-title.svg#gh-light-mode-only" alt="Uncloud logo"/>
|
||||||
<h1>Uncloud</h1>
|
<img src="./website/landing/images/logo-title-dark.svg#gh-dark-mode-only" alt="Uncloud logo"/>
|
||||||
<p><strong>Docker simplicity. Multi-machine power.</strong></p>
|
<p><strong>▸ Docker simplicity. Multi-machine power ◂</strong></p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://uncloud.run/docs"><img src="https://img.shields.io/badge/Docs-blue.svg?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation"></a>
|
<a href="https://uncloud.run/docs"><img src="https://img.shields.io/badge/Docs-blue.svg?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation"></a>
|
||||||
@@ -40,7 +40,7 @@ complexity of Kubernetes.
|
|||||||
* **No control plane**: Fully decentralised design eliminates single points of failure and reduces operational overhead.
|
* **No control plane**: Fully decentralised design eliminates single points of failure and reduces operational overhead.
|
||||||
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
||||||
model and troubleshooting.
|
model and troubleshooting.
|
||||||
* **Managed DNS**: Automatic DNS records `*.<id>.cluster.uncloud.run` for services with public access via managed
|
* **Managed DNS**: Automatic DNS records `*.xxxxxx.uncld.dev` for services with public access via managed
|
||||||
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
||||||
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
||||||
Encrypt.
|
Encrypt.
|
||||||
@@ -337,6 +337,10 @@ These companies and projects are helping Uncloud with their generous sponsorship
|
|||||||
|
|
||||||
## ❤️ Contributors
|
## ❤️ Contributors
|
||||||
|
|
||||||
|
<a href="https://trendshift.io/repositories/14069">
|
||||||
|
<img alt="Trendshift" src="https://trendshift.io/api/badge/repositories/14069" />
|
||||||
|
</a>
|
||||||
|
|
||||||
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
|
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
|
||||||
|
|
||||||
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
|
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ to cluster machines or --push-registry to upload them to external registries.`,
|
|||||||
|
|
||||||
return runBuild(cmd.Context(), uncli, opts)
|
return runBuild(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: "service",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil,
|
cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil,
|
||||||
@@ -74,8 +75,6 @@ to cluster machines or --push-registry to upload them to external registries.`,
|
|||||||
"Use --machine to specify which machines. (default is all machines)")
|
"Use --machine to specify which machines. (default is all machines)")
|
||||||
cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false,
|
cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false,
|
||||||
"Upload the built images to external registries (e.g., Docker Hub) after building.")
|
"Upload the built images to external registries (e.g., Docker Hub) after building.")
|
||||||
cmd.Flags().StringVarP(&opts.Context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ import (
|
|||||||
|
|
||||||
"github.com/alecthomas/chroma/v2/quick"
|
"github.com/alecthomas/chroma/v2/quick"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configOptions struct {
|
type configOptions struct {
|
||||||
machine string
|
machine string
|
||||||
noColor bool
|
noColor bool
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfigCommand() *cobra.Command {
|
func NewConfigCommand() *cobra.Command {
|
||||||
@@ -34,16 +32,12 @@ func NewConfigCommand() *cobra.Command {
|
|||||||
"Name or ID of the machine to get the configuration from. (default is connected machine)")
|
"Name or ID of the machine to get the configuration from. (default is connected machine)")
|
||||||
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
|
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
|
||||||
"Disable syntax highlighting for the output.")
|
"Disable syntax highlighting for the output.")
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -51,7 +45,7 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
|||||||
|
|
||||||
if opts.machine != "" {
|
if opts.machine != "" {
|
||||||
// If a specific machine is requested, use it to get the Caddy configuration.
|
// If a specific machine is requested, use it to get the Caddy configuration.
|
||||||
ctx, _, err = api.ProxyMachinesContext(ctx, clusterClient, []string{opts.machine})
|
ctx, _, err = clusterClient.ProxyMachinesContext(ctx, []string{opts.machine})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ type deployOptions struct {
|
|||||||
caddyfile string
|
caddyfile string
|
||||||
image string
|
image string
|
||||||
machines []string
|
machines []string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeployCommand() *cobra.Command {
|
func NewDeployCommand() *cobra.Command {
|
||||||
@@ -46,10 +45,6 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
|
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||||
"list. (default is all machines)")
|
"list. (default is all machines)")
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context to deploy to. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -64,7 +59,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
caddyfile = strings.TrimSpace(string(data))
|
caddyfile = strings.TrimSpace(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewConnectionCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "connection",
|
||||||
|
Short: "Choose a new default connection for the current context.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return selectConnection(uncli)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectConnection(uncli *cli.CLI) error {
|
||||||
|
if uncli.Config == nil {
|
||||||
|
return fmt.Errorf("connection management is not available: Uncloud configuration file is not being used")
|
||||||
|
}
|
||||||
|
if len(uncli.Config.Contexts) == 0 {
|
||||||
|
return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path())
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCtxName := uncli.Config.CurrentContext
|
||||||
|
currentCtx, ok := uncli.Config.Contexts[currentCtxName]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("current context '%s' not found", currentCtxName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(currentCtx.Connections) == 0 {
|
||||||
|
return fmt.Errorf("no connections found in context '%s'", currentCtxName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedConnection int
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[int]().
|
||||||
|
Title("Select a default connection").
|
||||||
|
Options(buildConnectionOptions(currentCtx.Connections)...).
|
||||||
|
Value(&selectedConnection),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return fmt.Errorf("select connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCtx.SetDefaultConnection(selectedConnection)
|
||||||
|
selected := currentCtx.Connections[0]
|
||||||
|
|
||||||
|
if err := uncli.Config.Save(); err != nil {
|
||||||
|
return fmt.Errorf("save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Default connection for context '%s' is now '%s'.\n", currentCtxName, selected)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildConnectionOptions(connections []config.MachineConnection) []huh.Option[int] {
|
||||||
|
options := make([]huh.Option[int], len(connections))
|
||||||
|
for i, conn := range connections {
|
||||||
|
key := conn.String()
|
||||||
|
opt := huh.NewOption(key, i)
|
||||||
|
if i == 0 {
|
||||||
|
opt.Key += " (default)"
|
||||||
|
opt = opt.Selected(true)
|
||||||
|
}
|
||||||
|
options[i] = opt
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewListCommand(),
|
NewListCommand(),
|
||||||
NewUseCommand(),
|
NewUseCommand(),
|
||||||
|
NewConnectionCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ type deployOptions struct {
|
|||||||
noBuild bool
|
noBuild bool
|
||||||
recreate bool
|
recreate bool
|
||||||
yes bool
|
yes bool
|
||||||
|
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
||||||
@@ -44,6 +42,7 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
|
|
||||||
return runDeploy(cmd.Context(), uncli, opts)
|
return runDeploy(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: "service",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil,
|
cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil,
|
||||||
@@ -51,8 +50,6 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
|
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
|
||||||
cmd.Flags().BoolVar(&opts.BuildServicesOptions.Pull, "build-pull", false,
|
cmd.Flags().BoolVar(&opts.BuildServicesOptions.Pull, "build-pull", false,
|
||||||
"Always attempt to pull newer versions of base images before building service images.")
|
"Always attempt to pull newer versions of base images before building service images.")
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context to deploy to (default is the current context)")
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||||
"One or more Compose files to deploy services from. (default compose.yaml)")
|
"One or more Compose files to deploy services from. (default compose.yaml)")
|
||||||
cmd.Flags().BoolVar(&opts.noBuild, "no-build", false,
|
cmd.Flags().BoolVar(&opts.noBuild, "no-build", false,
|
||||||
@@ -108,7 +105,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,32 +12,21 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
type releaseOptions struct {
|
|
||||||
context string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReleaseCommand() *cobra.Command {
|
func NewReleaseCommand() *cobra.Command {
|
||||||
opts := releaseOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "release",
|
Use: "release",
|
||||||
Short: "Release the reserved cluster domain.",
|
Short: "Release the reserved cluster domain.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return release(cmd.Context(), uncli, opts)
|
return release(cmd.Context(), uncli)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func release(ctx context.Context, uncli *cli.CLI, opts releaseOptions) error {
|
func release(ctx context.Context, uncli *cli.CLI) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
|
|||||||
|
|
||||||
type reserveOptions struct {
|
type reserveOptions struct {
|
||||||
endpoint string
|
endpoint string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReserveCommand() *cobra.Command {
|
func NewReserveCommand() *cobra.Command {
|
||||||
@@ -36,16 +35,12 @@ func NewReserveCommand() *cobra.Command {
|
|||||||
|
|
||||||
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
|
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
|
||||||
"API endpoint for the Uncloud DNS service.")
|
"API endpoint for the Uncloud DNS service.")
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Use: "dns",
|
Use: "dns",
|
||||||
Short: "Manage cluster domain in Uncloud DNS.",
|
Short: "Manage cluster domain in Uncloud DNS.",
|
||||||
Long: "Manage cluster domain in Uncloud DNS.\n" +
|
Long: "Manage cluster domain in Uncloud DNS.\n" +
|
||||||
"DNS commands allow you to reserve or release a unique '<id>.cluster.uncloud.run' domain for your " +
|
"DNS commands allow you to reserve or release a unique 'xxxxxx.uncld.dev' domain for your " +
|
||||||
"cluster. When reserved, Caddy service deployments will automatically update DNS records to route " +
|
"cluster. When reserved, Caddy service deployments will automatically update DNS records to route " +
|
||||||
"traffic to the services in the cluster.",
|
"traffic to the services in the cluster.",
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-14
@@ -10,32 +10,21 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type showOptions struct {
|
|
||||||
context string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewShowCommand() *cobra.Command {
|
func NewShowCommand() *cobra.Command {
|
||||||
opts := showOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "show",
|
Use: "show",
|
||||||
Short: "Print the cluster domain name.",
|
Short: "Print the cluster domain name.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return show(cmd.Context(), uncli, opts)
|
return show(cmd.Context(), uncli)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
func show(ctx context.Context, uncli *cli.CLI) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
type listOptions struct {
|
type listOptions struct {
|
||||||
machines []string
|
machines []string
|
||||||
nameFilter string
|
nameFilter string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
@@ -60,10 +59,6 @@ func NewListCommand() *cobra.Command {
|
|||||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
|
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
|
||||||
"(default is include all machines)")
|
"(default is include all machines)")
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -82,7 +77,7 @@ type imageRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type pushOptions struct {
|
|||||||
image string
|
image string
|
||||||
machines []string
|
machines []string
|
||||||
platform string
|
platform string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPushCommand() *cobra.Command {
|
func NewPushCommand() *cobra.Command {
|
||||||
@@ -54,16 +53,12 @@ The image is uploaded to all cluster machines (default) or the specified machine
|
|||||||
"Push a specific platform of a multi-platform image (e.g., linux/amd64, linux/arm64).\n"+
|
"Push a specific platform of a multi-platform image (e.g., linux/amd64, linux/arm64).\n"+
|
||||||
"Local Docker must be configured to use containerd image store to support multi-platform images.",
|
"Local Docker must be configured to use containerd image store to support multi-platform images.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
|
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
@@ -25,7 +26,6 @@ type addOptions struct {
|
|||||||
noInstall bool
|
noInstall bool
|
||||||
publicIP string
|
publicIP string
|
||||||
sshKey string
|
sshKey string
|
||||||
context string
|
|
||||||
version string
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +34,22 @@ func NewAddCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add [USER@]HOST[:PORT]",
|
Use: "add [USER@]HOST[:PORT]",
|
||||||
Short: "Add a remote machine to a cluster.",
|
Short: "Add a remote machine to a cluster.",
|
||||||
|
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)`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
user, host, port, err := config.SSHDestination(args[0]).Parse()
|
// Determine if SSH CLI needs to be used and strip scheme
|
||||||
|
destination := args[0]
|
||||||
|
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
|
||||||
|
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
||||||
|
destination = strings.TrimPrefix(destination, "ssh://")
|
||||||
|
|
||||||
|
user, host, port, err := config.SSHDestination(destination).Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse remote machine: %w", err)
|
return fmt.Errorf("parse remote machine: %w", err)
|
||||||
}
|
}
|
||||||
@@ -47,6 +58,7 @@ func NewAddCommand() *cobra.Command {
|
|||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
KeyPath: opts.sshKey,
|
KeyPath: opts.sshKey,
|
||||||
|
UseSSHCLI: useSSHCLI,
|
||||||
}
|
}
|
||||||
|
|
||||||
return add(cmd.Context(), uncli, remoteMachine, opts)
|
return add(cmd.Context(), uncli, remoteMachine, opts)
|
||||||
@@ -76,10 +88,6 @@ func NewAddCommand() *cobra.Command {
|
|||||||
&opts.version, "version", "latest",
|
&opts.version, "version", "latest",
|
||||||
"Version of the Uncloud daemon to install on the machine.",
|
"Version of the Uncloud daemon to install on the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context to add the machine to. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -100,7 +108,6 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
|||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
||||||
Context: opts.context,
|
|
||||||
MachineName: opts.name,
|
MachineName: opts.name,
|
||||||
PublicIP: publicIP,
|
PublicIP: publicIP,
|
||||||
RemoteMachine: remoteMachine,
|
RemoteMachine: remoteMachine,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
@@ -32,10 +33,14 @@ type initOptions struct {
|
|||||||
func NewInitCommand() *cobra.Command {
|
func NewInitCommand() *cobra.Command {
|
||||||
opts := initOptions{}
|
opts := initOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init [USER@HOST:PORT]",
|
Use: "init [schema://]USER@HOST[:PORT]",
|
||||||
Short: "Initialise a new cluster with a remote machine as the first member.",
|
Short: "Initialise a new cluster with a remote machine as the first member.",
|
||||||
Long: "Initialise a new cluster by setting up a remote machine as the first member.\n" +
|
Long: `Initialise a new cluster by setting up a remote machine as the first member.
|
||||||
"This command creates a new context in your Uncloud config to manage the cluster.",
|
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)`,
|
||||||
Example: ` # Initialise a new cluster with default settings.
|
Example: ` # Initialise a new cluster with default settings.
|
||||||
uc machine init root@<your-server-ip>
|
uc machine init root@<your-server-ip>
|
||||||
|
|
||||||
@@ -45,7 +50,7 @@ func NewInitCommand() *cobra.Command {
|
|||||||
# Initialise with a non-root user and custom SSH port and key.
|
# Initialise with a non-root user and custom SSH port and key.
|
||||||
uc machine init ubuntu@<your-server-ip>:2222 -i ~/.ssh/mykey
|
uc machine init ubuntu@<your-server-ip>:2222 -i ~/.ssh/mykey
|
||||||
|
|
||||||
# Initialise without Caddy (no reverse proxy) and without an automatically managed domain name (xxxxxx.cluster.uncloud.run).
|
# Initialise without Caddy (no reverse proxy) and without an automatically managed domain name (xxxxxx.uncld.dev).
|
||||||
# You can deploy Caddy with 'uc caddy deploy' and reserve a domain with 'uc dns reserve' later.
|
# You can deploy Caddy with 'uc caddy deploy' and reserve a domain with 'uc dns reserve' later.
|
||||||
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
|
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
|
||||||
// TODO: support initialising a cluster on the local machine.
|
// TODO: support initialising a cluster on the local machine.
|
||||||
@@ -55,7 +60,13 @@ func NewInitCommand() *cobra.Command {
|
|||||||
|
|
||||||
var remoteMachine *cli.RemoteMachine
|
var remoteMachine *cli.RemoteMachine
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
user, host, port, err := config.SSHDestination(args[0]).Parse()
|
// Determine if SSH CLI is requested and strip scheme
|
||||||
|
destination := args[0]
|
||||||
|
useSSHCLI := strings.HasPrefix(destination, "ssh+cli://")
|
||||||
|
destination = strings.TrimPrefix(destination, "ssh+cli://")
|
||||||
|
destination = strings.TrimPrefix(destination, "ssh://")
|
||||||
|
|
||||||
|
user, host, port, err := config.SSHDestination(destination).Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse remote machine: %w", err)
|
return fmt.Errorf("parse remote machine: %w", err)
|
||||||
}
|
}
|
||||||
@@ -64,6 +75,7 @@ func NewInitCommand() *cobra.Command {
|
|||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
KeyPath: opts.sshKey,
|
KeyPath: opts.sshKey,
|
||||||
|
UseSSHCLI: useSSHCLI,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +128,11 @@ func NewInitCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
|
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
|
||||||
|
if uncli.Config == nil {
|
||||||
|
// Config is nil when connecting directly to a remote machine (--connect) without using Uncloud config.
|
||||||
|
return fmt.Errorf("do not specify --connect when initialising a new cluster")
|
||||||
|
}
|
||||||
|
|
||||||
netPrefix, err := netip.ParsePrefix(opts.network)
|
netPrefix, err := netip.ParsePrefix(opts.network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse network CIDR: %w", err)
|
return fmt.Errorf("parse network CIDR: %w", err)
|
||||||
|
|||||||
@@ -14,25 +14,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
var contextName string
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List machines in a cluster.",
|
Short: "List machines in a cluster.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return list(cmd.Context(), uncli, contextName)
|
return list(cmd.Context(), uncli)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&contextName, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
func list(ctx context.Context, uncli *cli.CLI) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, clusterName)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewRenameCommand() *cobra.Command {
|
func NewRenameCommand() *cobra.Command {
|
||||||
var contextName string
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rename OLD_NAME NEW_NAME",
|
Use: "rename OLD_NAME NEW_NAME",
|
||||||
Short: "Rename a machine in the cluster.",
|
Short: "Rename a machine in the cluster.",
|
||||||
@@ -20,18 +19,14 @@ configuration including network settings, public IP, and cluster membership.`,
|
|||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return rename(cmd.Context(), uncli, contextName, args[0], args[1])
|
return rename(cmd.Context(), uncli, args[0], args[1])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&contextName, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func rename(ctx context.Context, uncli *cli.CLI, contextName, oldName, newName string) error {
|
func rename(ctx context.Context, uncli *cli.CLI, oldName, newName string) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, contextName)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
type removeOptions struct {
|
type removeOptions struct {
|
||||||
noReset bool
|
noReset bool
|
||||||
yes bool
|
yes bool
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRmCommand() *cobra.Command {
|
func NewRmCommand() *cobra.Command {
|
||||||
@@ -39,8 +38,6 @@ func NewRmCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
"Do not prompt for confirmation before removing the machine.")
|
"Do not prompt for confirmation before removing the machine.")
|
||||||
cmd.Flags().BoolVar(&opts.noReset, "no-reset", false,
|
cmd.Flags().BoolVar(&opts.noReset, "no-reset", false,
|
||||||
@@ -51,14 +48,14 @@ func NewRmCommand() *cobra.Command {
|
|||||||
|
|
||||||
func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOptions) error {
|
func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOptions) error {
|
||||||
// TODO: automatically choose a connection to the machine that is not being removed.
|
// TODO: automatically choose a connection to the machine that is not being removed.
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
// Verify the machine exists and list all service containers on it including stopped ones.
|
// Verify the machine exists and list all service containers on it including stopped ones.
|
||||||
mctx, machines, err := api.ProxyMachinesContext(ctx, client, []string{nameOrID})
|
mctx, machines, err := client.ProxyMachinesContext(ctx, []string{nameOrID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -155,8 +152,21 @@ func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOpt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: remove the connection to the machine from the uncloud config if it exists. We need a way to associate
|
// Remove the connection to the machine from the uncloud config if it exists.
|
||||||
// the machine with its connection in the config, e.g. by storing the machine name in the connection metadata.
|
if uncli.Config != nil {
|
||||||
|
contextName := uncli.GetContextOverrideOrCurrent()
|
||||||
|
if context, ok := uncli.Config.Contexts[contextName]; ok {
|
||||||
|
for i, c := range context.Connections {
|
||||||
|
if c.MachineID == m.Id {
|
||||||
|
context.Connections = slices.Delete(context.Connections, i, i+1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := uncli.Config.Save(); err != nil {
|
||||||
|
return fmt.Errorf("save config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: If Caddy was running on this machine and a cluster domain is reserved,
|
// TODO: If Caddy was running on this machine and a cluster domain is reserved,
|
||||||
// let the user know that the DNS records should be updated.
|
// let the user know that the DNS records should be updated.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "machine",
|
Use: "machine",
|
||||||
Aliases: []string{"m"},
|
Aliases: []string{"m"},
|
||||||
Short: "Manage machines in an Uncloud cluster.",
|
Short: "Manage machines in the cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewAddCommand(),
|
NewAddCommand(),
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
type updateOptions struct {
|
type updateOptions struct {
|
||||||
name string
|
name string
|
||||||
publicIP string
|
publicIP string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUpdateCommand() *cobra.Command {
|
func NewUpdateCommand() *cobra.Command {
|
||||||
@@ -43,10 +42,6 @@ At least one flag must be specified to perform an update operation.`,
|
|||||||
&opts.publicIP, "public-ip", "",
|
&opts.publicIP, "public-ip", "",
|
||||||
fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.", PublicIPNone),
|
fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.", PublicIPNone),
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -57,7 +52,7 @@ func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts update
|
|||||||
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip)")
|
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip)")
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-14
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
type globalOptions struct {
|
type globalOptions struct {
|
||||||
configPath string
|
configPath string
|
||||||
connect string
|
connect string
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -38,6 +40,7 @@ func main() {
|
|||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
|
cli.BindEnvToFlag(cmd, "connect", "UNCLOUD_CONNECT")
|
||||||
|
cli.BindEnvToFlag(cmd, "context", "UNCLOUD_CONTEXT")
|
||||||
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
|
cli.BindEnvToFlag(cmd, "uncloud-config", "UNCLOUD_CONFIG")
|
||||||
|
|
||||||
var conn *config.MachineConnection
|
var conn *config.MachineConnection
|
||||||
@@ -55,11 +58,12 @@ func main() {
|
|||||||
conn = &config.MachineConnection{
|
conn = &config.MachineConnection{
|
||||||
SSHCLI: config.SSHDestination(dest),
|
SSHCLI: config.SSHDestination(dest),
|
||||||
}
|
}
|
||||||
} else {
|
} else if strings.HasPrefix(opts.connect, "unix://") {
|
||||||
dest := opts.connect
|
conn = &config.MachineConnection{
|
||||||
if strings.HasPrefix(dest, "ssh://") {
|
Unix: opts.connect[len("unix://"):],
|
||||||
dest = dest[len("ssh://"):]
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
dest := strings.TrimPrefix(opts.connect, "ssh://")
|
||||||
conn = &config.MachineConnection{
|
conn = &config.MachineConnection{
|
||||||
SSH: config.SSHDestination(dest),
|
SSH: config.SSHDestination(dest),
|
||||||
}
|
}
|
||||||
@@ -67,7 +71,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configPath := fs.ExpandHomeDir(opts.configPath)
|
configPath := fs.ExpandHomeDir(opts.configPath)
|
||||||
uncli, err := cli.New(configPath, conn)
|
uncli, err := cli.New(configPath, conn, opts.context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialise CLI: %w", err)
|
return fmt.Errorf("initialise CLI: %w", err)
|
||||||
}
|
}
|
||||||
@@ -78,29 +82,57 @@ func main() {
|
|||||||
|
|
||||||
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||||
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
"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], or tcp://host:port")
|
"Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock")
|
||||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||||
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
||||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||||
// TODO: make --context a global flag and pass it as a value of the command context.
|
cmd.PersistentFlags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]")
|
||||||
|
|
||||||
|
// Set custom help function to show links to docs and Discord only for the root 'uc' command.
|
||||||
|
defaultHelpFunc := cmd.HelpFunc()
|
||||||
|
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
|
||||||
|
defaultHelpFunc(c, args)
|
||||||
|
// Only show links for the root 'uc' command.
|
||||||
|
if c.Name() == "uc" {
|
||||||
|
urlStyle := lipgloss.NewStyle().
|
||||||
|
Underline(true).
|
||||||
|
Foreground(lipgloss.Color("12")) // light blue
|
||||||
|
|
||||||
|
fmt.Fprintln(c.OutOrStdout())
|
||||||
|
fmt.Fprintf(c.OutOrStdout(), "Learn more about Uncloud: %s\n",
|
||||||
|
urlStyle.Render("https://uncloud.run/docs"))
|
||||||
|
fmt.Fprintf(c.OutOrStdout(), "Join our Discord community: %s\n",
|
||||||
|
urlStyle.Render("https://uncloud.run/discord"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd.AddGroup(&cobra.Group{
|
||||||
|
ID: "service",
|
||||||
|
Title: "Deploy and manage services:",
|
||||||
|
})
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
NewBuildCommand(),
|
||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
NewDocsCommand(),
|
NewDocsCommand(),
|
||||||
NewBuildCommand(),
|
|
||||||
NewImagesCommand(),
|
NewImagesCommand(),
|
||||||
|
NewPsCommand(),
|
||||||
caddy.NewRootCommand(),
|
caddy.NewRootCommand(),
|
||||||
cmdcontext.NewRootCommand(),
|
cmdcontext.NewRootCommand(),
|
||||||
dns.NewRootCommand(),
|
dns.NewRootCommand(),
|
||||||
image.NewRootCommand(),
|
image.NewRootCommand(),
|
||||||
machine.NewRootCommand(),
|
machine.NewRootCommand(),
|
||||||
service.NewRootCommand(),
|
service.NewRootCommand(),
|
||||||
service.NewExecCommand(),
|
service.NewExecCommand("service"),
|
||||||
service.NewInspectCommand(),
|
service.NewInspectCommand("service"),
|
||||||
service.NewListCommand(),
|
service.NewListCommand("service"),
|
||||||
service.NewRmCommand(),
|
service.NewLogsCommand("service"),
|
||||||
service.NewRunCommand(),
|
service.NewRmCommand("service"),
|
||||||
service.NewScaleCommand(),
|
service.NewRunCommand("service"),
|
||||||
|
service.NewScaleCommand("service"),
|
||||||
|
service.NewStartCommand("service"),
|
||||||
|
service.NewStopCommand("service"),
|
||||||
volume.NewRootCommand(),
|
volume.NewRootCommand(),
|
||||||
)
|
)
|
||||||
cobra.CheckErr(cmd.Execute())
|
cobra.CheckErr(cmd.Execute())
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh/spinner"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/lipgloss/table"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sortByService = "service"
|
||||||
|
sortByMachine = "machine"
|
||||||
|
sortByHealth = "health"
|
||||||
|
)
|
||||||
|
|
||||||
|
type containerHighlight int
|
||||||
|
|
||||||
|
const (
|
||||||
|
highlightDanger containerHighlight = iota
|
||||||
|
highlightWarning
|
||||||
|
highlightSuccess
|
||||||
|
highlightNormal
|
||||||
|
)
|
||||||
|
|
||||||
|
type psOptions struct {
|
||||||
|
sortBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPsCommand() *cobra.Command {
|
||||||
|
opts := psOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ps",
|
||||||
|
Short: "List all service containers.",
|
||||||
|
Long: `List all service containers across all machines in the cluster.
|
||||||
|
|
||||||
|
This command provides a comprehensive overview of all running containers that are part of a service,
|
||||||
|
making it easy to see the distribution and status of containers across the cluster.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
if opts.sortBy != sortByService && opts.sortBy != sortByMachine && opts.sortBy != sortByHealth {
|
||||||
|
return fmt.Errorf("invalid value for --sort: %q, must be one of '%s', '%s' or '%s'", opts.sortBy,
|
||||||
|
sortByService, sortByMachine, sortByHealth)
|
||||||
|
}
|
||||||
|
|
||||||
|
return runPs(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
GroupID: "service",
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&opts.sortBy, "sort", "s", sortByService,
|
||||||
|
"Sort containers by 'service', 'machine', or 'health'.")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerInfo struct {
|
||||||
|
serviceName string
|
||||||
|
machineName string
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
image string
|
||||||
|
status string
|
||||||
|
highlight containerHighlight
|
||||||
|
created time.Time
|
||||||
|
ip string
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPs(ctx context.Context, uncli *cli.CLI, opts psOptions) error {
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
var containers []containerInfo
|
||||||
|
err = spinner.New().
|
||||||
|
Title(" Collecting container info...").
|
||||||
|
Type(spinner.MiniDot).
|
||||||
|
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
|
||||||
|
ActionWithErr(func(ctx context.Context) error {
|
||||||
|
containers, err = collectContainers(ctx, clusterClient)
|
||||||
|
return err
|
||||||
|
}).
|
||||||
|
Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("collect containers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the containers based on the sorting option.
|
||||||
|
sort.SliceStable(containers, func(i, j int) bool {
|
||||||
|
a, b := containers[i], containers[j]
|
||||||
|
switch opts.sortBy {
|
||||||
|
case sortByHealth:
|
||||||
|
if a.highlight != b.highlight {
|
||||||
|
return a.highlight < b.highlight
|
||||||
|
}
|
||||||
|
if a.serviceName != b.serviceName {
|
||||||
|
return a.serviceName < b.serviceName
|
||||||
|
}
|
||||||
|
case sortByMachine:
|
||||||
|
if a.machineName != b.machineName {
|
||||||
|
return a.machineName < b.machineName
|
||||||
|
}
|
||||||
|
if a.serviceName != b.serviceName {
|
||||||
|
return a.serviceName < b.serviceName
|
||||||
|
}
|
||||||
|
default: // sortByService
|
||||||
|
if a.serviceName != b.serviceName {
|
||||||
|
return a.serviceName < b.serviceName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to creation time (newest first).
|
||||||
|
return a.created.After(b.created)
|
||||||
|
})
|
||||||
|
|
||||||
|
return printContainers(containers)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Headers("SERVICE", "CONTAINER ID", "CONTAINER NAME", "IMAGE", "CREATED", "STATUS", "IP ADDRESS", "MACHINE")
|
||||||
|
|
||||||
|
for _, ctr := range containers {
|
||||||
|
id := ctr.id
|
||||||
|
if len(id) > 12 {
|
||||||
|
id = id[:12]
|
||||||
|
}
|
||||||
|
|
||||||
|
created := units.HumanDuration(time.Now().UTC().Sub(ctr.created)) + " ago"
|
||||||
|
|
||||||
|
var statusStyle lipgloss.Style
|
||||||
|
switch ctr.highlight {
|
||||||
|
case highlightSuccess:
|
||||||
|
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green
|
||||||
|
case highlightDanger:
|
||||||
|
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Red
|
||||||
|
case highlightWarning:
|
||||||
|
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // Yellow
|
||||||
|
default:
|
||||||
|
statusStyle = lipgloss.NewStyle() // Default
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Row(
|
||||||
|
ctr.serviceName,
|
||||||
|
id,
|
||||||
|
ctr.name,
|
||||||
|
ctr.image,
|
||||||
|
created,
|
||||||
|
statusStyle.Render(ctr.status),
|
||||||
|
ctr.ip,
|
||||||
|
ctr.machineName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(t)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo, error) {
|
||||||
|
listCtx, machines, err := cli.ProxyMachinesContext(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("proxy machines context: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of IP to machine name for resolving response metadata
|
||||||
|
machinesNamesByIP := make(map[string]string)
|
||||||
|
for _, m := range machines {
|
||||||
|
if addr, err := m.Machine.Network.ManagementIp.ToAddr(); err == nil {
|
||||||
|
machinesNamesByIP[addr.String()] = m.Machine.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all service containers across all machines in the cluster.
|
||||||
|
machineContainers, err := cli.Docker.ListServiceContainers(
|
||||||
|
listCtx, "", container.ListOptions{All: true},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list service containers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var containers []containerInfo
|
||||||
|
for _, msc := range machineContainers {
|
||||||
|
machineName := "unknown"
|
||||||
|
|
||||||
|
// Metadata can be nil if the request was broadcasted to only one machine.
|
||||||
|
if msc.Metadata == nil && len(machineContainers) > 1 {
|
||||||
|
return nil, fmt.Errorf("something went wrong with gRPC proxy: metadata is missing for a machine response")
|
||||||
|
}
|
||||||
|
if msc.Metadata != nil && msc.Metadata.Error != "" {
|
||||||
|
client.PrintWarning(fmt.Sprintf("failed to list containers on machine %s: %s", machineName,
|
||||||
|
msc.Metadata.Error))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if msc.Metadata != nil {
|
||||||
|
var ok bool
|
||||||
|
machineName, ok = machinesNamesByIP[msc.Metadata.Machine]
|
||||||
|
if !ok {
|
||||||
|
// Fallback to machine's IP as name.
|
||||||
|
machineName = msc.Metadata.Machine
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to the first available machine name.
|
||||||
|
if len(machines) > 0 {
|
||||||
|
machineName = machines[0].Machine.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ctr := range msc.Containers {
|
||||||
|
if ctr.Container.State == nil || ctr.Container.Config == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := ctr.Container.HumanState()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get human state for container %s: %w", ctr.Container.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var highlight containerHighlight
|
||||||
|
healthStatus := ""
|
||||||
|
if ctr.Container.State.Health != nil {
|
||||||
|
healthStatus = ctr.Container.State.Health.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if healthStatus == container.Unhealthy || ctr.Container.State.Status == "dead" || ctr.Container.State.OOMKilled || ctr.Container.State.Dead {
|
||||||
|
highlight = highlightDanger
|
||||||
|
} else if healthStatus == container.Healthy {
|
||||||
|
highlight = highlightSuccess
|
||||||
|
} else if ctr.Container.State.Status == "running" {
|
||||||
|
highlight = highlightNormal
|
||||||
|
} else { // Other non-critical but noteworthy states
|
||||||
|
highlight = highlightWarning
|
||||||
|
}
|
||||||
|
|
||||||
|
created, _ := time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
||||||
|
|
||||||
|
ip := ctr.Container.UncloudNetworkIP()
|
||||||
|
ipStr := ""
|
||||||
|
// The container might not have an IP if it's not running or uses the host network.
|
||||||
|
if ip.IsValid() {
|
||||||
|
ipStr = ip.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
info := 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,
|
||||||
|
}
|
||||||
|
containers = append(containers, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return containers, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockDockerClient implements pb.DockerClient
|
||||||
|
type mockDockerClient struct {
|
||||||
|
pb.DockerClient // Embed to avoid implementing all methods
|
||||||
|
listResp *pb.ListServiceContainersResponse
|
||||||
|
listErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ListServiceContainers(ctx context.Context, in *pb.ListServiceContainersRequest, opts ...grpc.CallOption) (*pb.ListServiceContainersResponse, error) {
|
||||||
|
return m.listResp, m.listErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockClusterClient implements pb.ClusterClient
|
||||||
|
type mockClusterClient struct {
|
||||||
|
pb.ClusterClient // Embed to avoid implementing all methods
|
||||||
|
machinesResp *pb.ListMachinesResponse
|
||||||
|
machinesErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockClusterClient) ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*pb.ListMachinesResponse, error) {
|
||||||
|
return m.machinesResp, m.machinesErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectContainers_NilMetadata(t *testing.T) {
|
||||||
|
// Setup container data
|
||||||
|
containerData := map[string]interface{}{
|
||||||
|
"Id": "container1",
|
||||||
|
"Name": "test-container",
|
||||||
|
"Config": map[string]interface{}{
|
||||||
|
"Image": "test-image",
|
||||||
|
},
|
||||||
|
"State": map[string]interface{}{
|
||||||
|
"Status": "running",
|
||||||
|
"StartedAt": "2023-01-01T12:00:00Z",
|
||||||
|
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
"NetworkSettings": map[string]interface{}{
|
||||||
|
"Networks": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
containerJSON, err := json.Marshal(containerData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
serviceSpecJSON, err := json.Marshal(map[string]interface{}{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
mockDocker := &mockDockerClient{
|
||||||
|
listResp: &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Metadata: nil, // Simulating the issue: nil metadata
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
machineIP := "10.0.0.1"
|
||||||
|
mockCluster := &mockClusterClient{
|
||||||
|
machinesResp: &pb.ListMachinesResponse{
|
||||||
|
Machines: []*pb.MachineMember{
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-1",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr(machineIP)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
State: pb.MachineMember_UP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct client with mocks
|
||||||
|
cli := &client.Client{
|
||||||
|
Docker: &docker.Client{
|
||||||
|
GRPCClient: mockDocker,
|
||||||
|
},
|
||||||
|
ClusterClient: mockCluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
containers, err := collectContainers(context.Background(), cli)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.Len(t, containers, 1)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectContainers_NilMetadata_MultipleMachines_Error(t *testing.T) {
|
||||||
|
// If we have multiple machines but receive nil metadata, it should return an error as it is ambiguous
|
||||||
|
|
||||||
|
// Setup container data
|
||||||
|
containerData1 := map[string]interface{}{
|
||||||
|
"Id": "container1",
|
||||||
|
}
|
||||||
|
containerJSON1, _ := json.Marshal(containerData1)
|
||||||
|
|
||||||
|
containerData2 := map[string]interface{}{
|
||||||
|
"Id": "container2",
|
||||||
|
"Config": map[string]interface{}{
|
||||||
|
"Image": "test-image",
|
||||||
|
},
|
||||||
|
"State": map[string]interface{}{
|
||||||
|
"Status": "running",
|
||||||
|
"StartedAt": "2023-01-01T12:00:00Z",
|
||||||
|
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
"NetworkSettings": map[string]interface{}{
|
||||||
|
"Networks": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
containerJSON2, _ := json.Marshal(containerData2)
|
||||||
|
|
||||||
|
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
mockDocker := &mockDockerClient{
|
||||||
|
listResp: &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Metadata: nil, // Nil metadata
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON1,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON2,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockCluster := &mockClusterClient{
|
||||||
|
machinesResp: &pb.ListMachinesResponse{
|
||||||
|
Machines: []*pb.MachineMember{
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-1",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-2",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct client with mocks
|
||||||
|
cli := &client.Client{
|
||||||
|
Docker: &docker.Client{
|
||||||
|
GRPCClient: mockDocker,
|
||||||
|
},
|
||||||
|
ClusterClient: mockCluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
_, err := collectContainers(context.Background(), cli)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "metadata is missing for a machine response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectContainers_MetadataPresent_MultipleMachines(t *testing.T) {
|
||||||
|
// Verify correct mapping of containers to machines when metadata is present
|
||||||
|
|
||||||
|
// Setup container data
|
||||||
|
containerData1 := map[string]interface{}{
|
||||||
|
"Id": "container1",
|
||||||
|
"Name": "container-1",
|
||||||
|
"Config": map[string]interface{}{
|
||||||
|
"Image": "image-1",
|
||||||
|
},
|
||||||
|
"State": map[string]interface{}{
|
||||||
|
"Status": "running",
|
||||||
|
"StartedAt": "2023-01-01T12:00:00Z",
|
||||||
|
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
"NetworkSettings": map[string]interface{}{
|
||||||
|
"Networks": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
containerJSON1, _ := json.Marshal(containerData1)
|
||||||
|
|
||||||
|
containerData2 := map[string]interface{}{
|
||||||
|
"Id": "container2",
|
||||||
|
"Name": "container-2",
|
||||||
|
"Config": map[string]interface{}{
|
||||||
|
"Image": "image-2",
|
||||||
|
},
|
||||||
|
"State": map[string]interface{}{
|
||||||
|
"Status": "running",
|
||||||
|
"StartedAt": "2023-01-01T12:00:00Z",
|
||||||
|
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
"NetworkSettings": map[string]interface{}{
|
||||||
|
"Networks": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
containerJSON2, _ := json.Marshal(containerData2)
|
||||||
|
|
||||||
|
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
mockDocker := &mockDockerClient{
|
||||||
|
listResp: &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Metadata: &pb.Metadata{Machine: "10.0.0.1"},
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON1,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Metadata: &pb.Metadata{Machine: "10.0.0.2"},
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON2,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockCluster := &mockClusterClient{
|
||||||
|
machinesResp: &pb.ListMachinesResponse{
|
||||||
|
Machines: []*pb.MachineMember{
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-1",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-2",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.2")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := &client.Client{
|
||||||
|
Docker: &docker.Client{GRPCClient: mockDocker},
|
||||||
|
ClusterClient: mockCluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
containers, err := collectContainers(context.Background(), cli)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, containers, 2)
|
||||||
|
// Order is not guaranteed by the map iteration in logic or parallel fetch (though here it's mocked sequential),
|
||||||
|
// but collectContainers just appends.
|
||||||
|
// We'll find them by ID.
|
||||||
|
for _, c := range containers {
|
||||||
|
if c.id == "container1" {
|
||||||
|
assert.Equal(t, "machine-1", c.machineName)
|
||||||
|
} else if c.id == "container2" {
|
||||||
|
assert.Equal(t, "machine-2", c.machineName)
|
||||||
|
} else {
|
||||||
|
t.Errorf("unexpected container id: %s", c.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectContainers_NilMetadata_NoMachines(t *testing.T) {
|
||||||
|
// Case: 1 msc with nil metadata but no machines at all
|
||||||
|
|
||||||
|
containerData := map[string]interface{}{
|
||||||
|
"Id": "container1",
|
||||||
|
"Config": map[string]interface{}{
|
||||||
|
"Image": "test-image",
|
||||||
|
},
|
||||||
|
"State": map[string]interface{}{
|
||||||
|
"Status": "running",
|
||||||
|
"StartedAt": "2023-01-01T12:00:00Z",
|
||||||
|
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
"NetworkSettings": map[string]interface{}{
|
||||||
|
"Networks": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
containerJSON, _ := json.Marshal(containerData)
|
||||||
|
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
|
||||||
|
|
||||||
|
mockDocker := &mockDockerClient{
|
||||||
|
listResp: &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Metadata: nil,
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// No machines in cluster response
|
||||||
|
mockCluster := &mockClusterClient{
|
||||||
|
machinesResp: &pb.ListMachinesResponse{
|
||||||
|
Machines: []*pb.MachineMember{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := &client.Client{
|
||||||
|
Docker: &docker.Client{GRPCClient: mockDocker},
|
||||||
|
ClusterClient: mockCluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
containers, err := collectContainers(context.Background(), cli)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, containers, 1)
|
||||||
|
if len(containers) > 0 {
|
||||||
|
assert.Equal(t, "unknown", containers[0].machineName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectContainers_MetadataPresent_NotInMapping(t *testing.T) {
|
||||||
|
// Case: msc with metadata that is not in the IP-to-name mapping
|
||||||
|
|
||||||
|
containerData := map[string]interface{}{
|
||||||
|
"Id": "container1",
|
||||||
|
"Config": map[string]interface{}{
|
||||||
|
"Image": "test-image",
|
||||||
|
},
|
||||||
|
"State": map[string]interface{}{
|
||||||
|
"Status": "running",
|
||||||
|
"StartedAt": "2023-01-01T12:00:00Z",
|
||||||
|
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
"NetworkSettings": map[string]interface{}{
|
||||||
|
"Networks": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
containerJSON, _ := json.Marshal(containerData)
|
||||||
|
serviceSpecJSON, _ := json.Marshal(map[string]interface{}{})
|
||||||
|
|
||||||
|
mockDocker := &mockDockerClient{
|
||||||
|
listResp: &pb.ListServiceContainersResponse{
|
||||||
|
Messages: []*pb.MachineServiceContainers{
|
||||||
|
{
|
||||||
|
Metadata: &pb.Metadata{Machine: "10.0.0.99"}, // Unknown IP
|
||||||
|
Containers: []*pb.ServiceContainer{
|
||||||
|
{
|
||||||
|
Container: containerJSON,
|
||||||
|
ServiceSpec: serviceSpecJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockCluster := &mockClusterClient{
|
||||||
|
machinesResp: &pb.ListMachinesResponse{
|
||||||
|
Machines: []*pb.MachineMember{
|
||||||
|
{
|
||||||
|
Machine: &pb.MachineInfo{
|
||||||
|
Name: "machine-1",
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
ManagementIp: pb.NewIP(netip.MustParseAddr("10.0.0.1")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := &client.Client{
|
||||||
|
Docker: &docker.Client{GRPCClient: mockDocker},
|
||||||
|
ClusterClient: mockCluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
containers, err := collectContainers(context.Background(), cli)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, containers, 1)
|
||||||
|
if len(containers) > 0 {
|
||||||
|
// Should fallback to the IP/string in metadata
|
||||||
|
assert.Equal(t, "10.0.0.99", containers[0].machineName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,18 +15,17 @@ type execCliOptions struct {
|
|||||||
detach bool
|
detach bool
|
||||||
interactive bool
|
interactive bool
|
||||||
noTty bool
|
noTty bool
|
||||||
context string
|
|
||||||
containerId string
|
containerId string
|
||||||
}
|
}
|
||||||
|
|
||||||
var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
|
var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
|
||||||
|
|
||||||
func NewExecCommand() *cobra.Command {
|
func NewExecCommand(groupID string) *cobra.Command {
|
||||||
opts := execCliOptions{}
|
opts := execCliOptions{}
|
||||||
|
|
||||||
execCmd := &cobra.Command{
|
execCmd := &cobra.Command{
|
||||||
Use: "exec [OPTIONS] SERVICE [COMMAND ARGS...]",
|
Use: "exec [OPTIONS] SERVICE [COMMAND ARGS...]",
|
||||||
Short: "Execute a command in a running service container",
|
Short: "Execute a command in a running service container.",
|
||||||
Long: `Execute a command (interactive shell by default) in a running container within a service.
|
Long: `Execute a command (interactive shell by default) in a running container within a service.
|
||||||
If the service has multiple replicas and no container ID is specified, the command will be executed in a random container.
|
If the service has multiple replicas and no container ID is specified, the command will be executed in a random container.
|
||||||
`,
|
`,
|
||||||
@@ -55,6 +54,7 @@ If the service has multiple replicas and no container ID is specified, the comma
|
|||||||
}
|
}
|
||||||
return runExec(cmd.Context(), uncli, serviceName, command, opts)
|
return runExec(cmd.Context(), uncli, serviceName, command, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: groupID,
|
||||||
}
|
}
|
||||||
|
|
||||||
execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background")
|
execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background")
|
||||||
@@ -69,9 +69,6 @@ If the service has multiple replicas and no container ID is specified, the comma
|
|||||||
execCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY")
|
execCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY")
|
||||||
execCmd.Flags().MarkHidden("tty")
|
execCmd.Flags().MarkHidden("tty")
|
||||||
|
|
||||||
execCmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
|
|
||||||
// Common flags
|
// Common flags
|
||||||
execCmd.Flags().StringVar(&opts.containerId, "container", "",
|
execCmd.Flags().StringVar(&opts.containerId, "container", "",
|
||||||
"ID of the container to exec into. Accepts full ID or a unique prefix "+
|
"ID of the container to exec into. Accepts full ID or a unique prefix "+
|
||||||
@@ -95,7 +92,7 @@ func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := uncli.ConnectClusterWithOptions(ctx, opts.context, cli.ConnectOptions{
|
client, err := uncli.ConnectClusterWithOptions(ctx, cli.ConnectOptions{
|
||||||
ShowProgress: false,
|
ShowProgress: false,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/pkg/stringid"
|
"github.com/docker/docker/pkg/stringid"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
service string
|
service string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInspectCommand() *cobra.Command {
|
func NewInspectCommand(groupID string) *cobra.Command {
|
||||||
opts := inspectOptions{}
|
opts := inspectOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "inspect SERVICE",
|
Use: "inspect SERVICE",
|
||||||
@@ -29,16 +30,13 @@ func NewInspectCommand() *cobra.Command {
|
|||||||
opts.service = args[0]
|
opts.service = args[0]
|
||||||
return inspect(cmd.Context(), uncli, opts)
|
return inspect(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: groupID,
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -63,18 +61,26 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
|||||||
fmt.Printf("Mode: %s\n", svc.Mode)
|
fmt.Printf("Mode: %s\n", svc.Mode)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
|
// Parse created times for sorting and display.
|
||||||
|
createdTimes := make(map[string]time.Time, len(svc.Containers))
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
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 {
|
||||||
|
return createdTimes[b.Container.ID].Compare(createdTimes[a.Container.ID])
|
||||||
|
})
|
||||||
|
|
||||||
// Print the list of containers in a table format.
|
// Print the list of containers in a table format.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tMACHINE"); err != nil {
|
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tIP ADDRESS\tMACHINE"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
for _, ctr := range svc.Containers {
|
for _, ctr := range svc.Containers {
|
||||||
createdAt, err := time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
created := units.HumanDuration(now.Sub(createdTimes[ctr.Container.ID])) + " ago"
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse created time: %w", err)
|
|
||||||
}
|
|
||||||
created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
|
||||||
|
|
||||||
machine := machinesNamesByID[ctr.MachineID]
|
machine := machinesNamesByID[ctr.MachineID]
|
||||||
if machine == "" {
|
if machine == "" {
|
||||||
@@ -85,13 +91,21 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
|||||||
return fmt.Errorf("get human state: %w", err)
|
return fmt.Errorf("get human state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ip := ctr.Container.UncloudNetworkIP()
|
||||||
|
ipStr := ""
|
||||||
|
// The container might not have an IP if it's not running or uses the host network.
|
||||||
|
if ip.IsValid() {
|
||||||
|
ipStr = ip.String()
|
||||||
|
}
|
||||||
|
|
||||||
_, err = fmt.Fprintf(
|
_, err = fmt.Fprintf(
|
||||||
tw,
|
tw,
|
||||||
"%s\t%s\t%s\t%s\t%s\n",
|
"%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
stringid.TruncateID(ctr.Container.ID),
|
stringid.TruncateID(ctr.Container.ID),
|
||||||
ctr.Container.Config.Image,
|
ctr.Container.Config.Image,
|
||||||
created,
|
created,
|
||||||
state,
|
state,
|
||||||
|
ipStr,
|
||||||
machine,
|
machine,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type logsOptions struct {
|
||||||
|
files []string
|
||||||
|
follow bool
|
||||||
|
tail string
|
||||||
|
since string
|
||||||
|
until string
|
||||||
|
utc bool
|
||||||
|
machines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogsCommand(groupID string) *cobra.Command {
|
||||||
|
var options logsOptions
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "logs [SERVICE...]",
|
||||||
|
Aliases: []string{"log"},
|
||||||
|
Short: "View service logs.",
|
||||||
|
Long: `View logs from all replicas of the specified service(s) across all machines in the cluster.
|
||||||
|
|
||||||
|
If no services are specified, streams logs from all services defined in the Compose file
|
||||||
|
(compose.yaml by default or the file(s) specified with --file).`,
|
||||||
|
Example: ` # View recent logs for a service.
|
||||||
|
uc logs web
|
||||||
|
|
||||||
|
# Stream logs in real-time (follow mode).
|
||||||
|
uc logs -f web
|
||||||
|
|
||||||
|
# View logs from multiple services.
|
||||||
|
uc logs web api db
|
||||||
|
|
||||||
|
# View logs from all services in compose.yaml.
|
||||||
|
uc logs
|
||||||
|
|
||||||
|
# Show last 20 lines per replica (default is 100).
|
||||||
|
uc logs -n 20 web
|
||||||
|
|
||||||
|
# Show all logs without line limit.
|
||||||
|
uc logs -n all web
|
||||||
|
|
||||||
|
# View logs from a specific time range.
|
||||||
|
uc logs --since 3h --until 1h30m web
|
||||||
|
|
||||||
|
# View logs only from replicas running on specific machines.
|
||||||
|
uc logs -m machine1,machine2 web api`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return runLogs(cmd.Context(), uncli, args, options)
|
||||||
|
},
|
||||||
|
GroupID: groupID,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringSliceVar(&options.files, "file", nil,
|
||||||
|
"One or more Compose files to load service names from when no services are specified. (default compose.yaml)")
|
||||||
|
cmd.Flags().BoolVarP(&options.follow, "follow", "f", false,
|
||||||
|
"Continually stream new logs.")
|
||||||
|
cmd.Flags().StringSliceVarP(&options.machines, "machine", "m", nil,
|
||||||
|
"Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.")
|
||||||
|
cmd.Flags().StringVar(&options.since, "since", "",
|
||||||
|
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||||
|
"Examples:\n"+
|
||||||
|
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
|
||||||
|
" --since 1h Relative duration (1 hour ago)\n"+
|
||||||
|
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
|
||||||
|
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
|
||||||
|
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
||||||
|
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
|
||||||
|
cmd.Flags().StringVarP(&options.tail, "tail", "n", "100",
|
||||||
|
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.")
|
||||||
|
cmd.Flags().StringVar(&options.until, "until", "",
|
||||||
|
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||||
|
"See --since for examples.")
|
||||||
|
cmd.Flags().BoolVar(&options.utc, "utc", false,
|
||||||
|
"Print timestamps in UTC instead of local timezone.")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogs(ctx context.Context, uncli *cli.CLI, serviceNames []string, opts logsOptions) error {
|
||||||
|
// If no services specified, try to load them from the Compose file(s).
|
||||||
|
if len(serviceNames) == 0 {
|
||||||
|
project, err := compose.LoadProject(ctx, opts.files)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
|
}
|
||||||
|
// View logs for all services, including disabled by inactive profiles.
|
||||||
|
serviceNames = append(project.ServiceNames(), project.DisabledServiceNames()...)
|
||||||
|
if len(serviceNames) == 0 {
|
||||||
|
return errors.New("no services found in compose file(s)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse tail option.
|
||||||
|
tail := -1
|
||||||
|
if opts.tail != "all" {
|
||||||
|
tailInt, err := strconv.Atoi(opts.tail)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid --tail value '%s': %w", opts.tail, err)
|
||||||
|
}
|
||||||
|
tail = tailInt
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := uncli.ConnectCluster(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
logsOpts := api.ServiceLogsOptions{
|
||||||
|
Follow: opts.follow,
|
||||||
|
Tail: tail,
|
||||||
|
Since: opts.since,
|
||||||
|
Until: opts.until,
|
||||||
|
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect log streams from all services.
|
||||||
|
machineIDsSet := mapset.NewSet[string]()
|
||||||
|
svcStreams := make([]<-chan api.ServiceLogEntry, 0, len(serviceNames))
|
||||||
|
for _, serviceName := range serviceNames {
|
||||||
|
svc, ch, err := c.ServiceLogs(ctx, serviceName, logsOpts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stream logs for service '%s': %w", serviceName, err)
|
||||||
|
}
|
||||||
|
svcStreams = append(svcStreams, ch)
|
||||||
|
|
||||||
|
machineIDs := svc.MachineIDs()
|
||||||
|
machineIDsSet.Append(machineIDs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream <-chan api.ServiceLogEntry
|
||||||
|
if len(serviceNames) == 1 {
|
||||||
|
stream = svcStreams[0]
|
||||||
|
} else {
|
||||||
|
// Merge all service streams into a single sorted stream without stall detection as its handled per-service.
|
||||||
|
merger := client.NewLogMerger(svcStreams, client.LogMergerOptions{})
|
||||||
|
stream = merger.Stream()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch machine names for all machines (machineIDsSet) service containers are running on.
|
||||||
|
machines, err := c.ListMachines(ctx, &api.MachineFilter{NamesOrIDs: machineIDsSet.ToSlice()})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
machineNames := make([]string, 0, len(machines))
|
||||||
|
for _, m := range machines {
|
||||||
|
machineNames = append(machineNames, m.Machine.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatter := newLogFormatter(machineNames, serviceNames, opts.utc)
|
||||||
|
|
||||||
|
// Print merged logs.
|
||||||
|
for entry := range stream {
|
||||||
|
if entry.Err != nil {
|
||||||
|
formatter.printError(entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
formatter.printEntry(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available colors for machine/service differentiation.
|
||||||
|
var colorPalette = []lipgloss.Color{
|
||||||
|
lipgloss.Color("10"), // Bright green
|
||||||
|
lipgloss.Color("11"), // Bright yellow
|
||||||
|
lipgloss.Color("12"), // Bright blue
|
||||||
|
lipgloss.Color("13"), // Bright magenta
|
||||||
|
lipgloss.Color("14"), // Bright cyan
|
||||||
|
lipgloss.Color("2"), // Green
|
||||||
|
lipgloss.Color("3"), // Yellow
|
||||||
|
lipgloss.Color("4"), // Blue
|
||||||
|
lipgloss.Color("5"), // Magenta
|
||||||
|
lipgloss.Color("6"), // Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
// logFormatter handles formatting and printing of log entries with dynamic column alignment.
|
||||||
|
type logFormatter struct {
|
||||||
|
machineNames []string
|
||||||
|
serviceNames []string
|
||||||
|
|
||||||
|
maxMachineWidth int
|
||||||
|
maxServiceWidth int
|
||||||
|
|
||||||
|
utc bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLogFormatter(machineNames, serviceNames []string, utc bool) *logFormatter {
|
||||||
|
slices.Sort(machineNames)
|
||||||
|
slices.Sort(serviceNames)
|
||||||
|
|
||||||
|
maxMachineWidth := 0
|
||||||
|
for _, name := range machineNames {
|
||||||
|
if len(name) > maxMachineWidth {
|
||||||
|
maxMachineWidth = len(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxServiceWidth := 0
|
||||||
|
for _, name := range serviceNames {
|
||||||
|
if len(name) > maxServiceWidth {
|
||||||
|
maxServiceWidth = len(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &logFormatter{
|
||||||
|
machineNames: machineNames,
|
||||||
|
serviceNames: serviceNames,
|
||||||
|
maxMachineWidth: maxMachineWidth,
|
||||||
|
maxServiceWidth: maxServiceWidth,
|
||||||
|
utc: utc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTimestamp formats timestamp using local timezone or UTC if configured.
|
||||||
|
func (f *logFormatter) formatTimestamp(t time.Time) string {
|
||||||
|
if f.utc {
|
||||||
|
t = t.UTC()
|
||||||
|
} else {
|
||||||
|
t = t.In(time.Local)
|
||||||
|
}
|
||||||
|
dimStyle := lipgloss.NewStyle().Faint(true)
|
||||||
|
|
||||||
|
return dimStyle.Render(t.Format(time.StampMilli))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *logFormatter) formatMachine(name string) string {
|
||||||
|
style := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxMachineWidth - len(name))
|
||||||
|
|
||||||
|
if len(f.serviceNames) == 1 {
|
||||||
|
// Machine name is coloured for single-service logs.
|
||||||
|
i := slices.Index(f.machineNames, name)
|
||||||
|
if i == -1 {
|
||||||
|
f.machineNames = append(f.machineNames, name)
|
||||||
|
i = len(f.machineNames) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
style = style.Foreground(colorPalette[i%len(colorPalette)])
|
||||||
|
}
|
||||||
|
|
||||||
|
return style.Render(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *logFormatter) formatServiceContainer(serviceName, containerID string) string {
|
||||||
|
styleService := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxServiceWidth - len(serviceName))
|
||||||
|
styleContainer := lipgloss.NewStyle().Faint(true)
|
||||||
|
|
||||||
|
if len(f.serviceNames) > 1 {
|
||||||
|
// Service name is coloured for multi-service logs.
|
||||||
|
i := slices.Index(f.serviceNames, serviceName)
|
||||||
|
if i == -1 {
|
||||||
|
f.serviceNames = append(f.serviceNames, serviceName)
|
||||||
|
i = len(f.serviceNames) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
styleService = styleService.Foreground(colorPalette[i%len(colorPalette)])
|
||||||
|
}
|
||||||
|
|
||||||
|
return styleService.Render(serviceName) + styleContainer.Render("["+containerID[:5]+"]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// printEntry prints a single log entry with proper formatting.
|
||||||
|
func (f *logFormatter) printEntry(entry api.ServiceLogEntry) {
|
||||||
|
if entry.Stream != api.LogStreamStdout && entry.Stream != api.LogStreamStderr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var output strings.Builder
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
output.WriteString(f.formatTimestamp(entry.Timestamp))
|
||||||
|
output.WriteString(" ")
|
||||||
|
|
||||||
|
// Machine name
|
||||||
|
output.WriteString(f.formatMachine(entry.Metadata.MachineName))
|
||||||
|
output.WriteString(" ")
|
||||||
|
|
||||||
|
// Service[container_id]
|
||||||
|
output.WriteString(f.formatServiceContainer(entry.Metadata.ServiceName, entry.Metadata.ContainerID))
|
||||||
|
output.WriteString(" ")
|
||||||
|
|
||||||
|
// Message
|
||||||
|
output.Write(entry.Message)
|
||||||
|
|
||||||
|
// Print to appropriate stream.
|
||||||
|
if entry.Stream == api.LogStreamStderr {
|
||||||
|
fmt.Fprint(os.Stderr, output.String())
|
||||||
|
} else {
|
||||||
|
fmt.Print(output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printError prints an error entry (e.g., stalled stream warning).
|
||||||
|
func (f *logFormatter) printError(entry api.ServiceLogEntry) {
|
||||||
|
if entry.Metadata.ContainerID != "" {
|
||||||
|
msg := fmt.Sprintf("WARNING: log stream from %s[%s] on machine '%s'",
|
||||||
|
entry.Metadata.ServiceName,
|
||||||
|
stringid.TruncateID(entry.Metadata.ContainerID),
|
||||||
|
entry.Metadata.MachineName)
|
||||||
|
|
||||||
|
if errors.Is(entry.Err, api.ErrLogStreamStalled) {
|
||||||
|
msg += " stopped responding"
|
||||||
|
} else {
|
||||||
|
msg += fmt.Sprintf(": %v", entry.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow
|
||||||
|
fmt.Fprintln(os.Stderr, style.Render(msg))
|
||||||
|
} else {
|
||||||
|
msg := fmt.Sprintf("ERROR: %v", entry.Err)
|
||||||
|
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) // Bold bright red
|
||||||
|
fmt.Fprintln(os.Stderr, style.Render(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-15
@@ -4,33 +4,31 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand(groupID string) *cobra.Command {
|
||||||
var contextName string
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list"},
|
Aliases: []string{"list"},
|
||||||
Short: "List services.",
|
Short: "List services.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
return list(cmd.Context(), uncli, contextName)
|
return list(cmd.Context(), uncli)
|
||||||
},
|
},
|
||||||
|
GroupID: groupID,
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&contextName, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI, contextName string) error {
|
func list(ctx context.Context, uncli *cli.CLI) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, contextName)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -41,15 +39,15 @@ func list(ctx context.Context, uncli *cli.CLI, contextName string) error {
|
|||||||
return fmt.Errorf("list services: %w", err)
|
return fmt.Errorf("list services: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceNames := make(map[string]struct{}, len(services))
|
// Sort services by name.
|
||||||
haveDuplicateNames := false
|
haveDuplicateNames := false
|
||||||
for _, svc := range services {
|
slices.SortFunc(services, func(a, b api.Service) int {
|
||||||
if _, exists := serviceNames[svc.Name]; exists {
|
if a.Name == b.Name {
|
||||||
haveDuplicateNames = true
|
haveDuplicateNames = true
|
||||||
break
|
return strings.Compare(a.ID, b.ID)
|
||||||
}
|
|
||||||
serviceNames[svc.Name] = struct{}{}
|
|
||||||
}
|
}
|
||||||
|
return strings.Compare(a.Name, b.Name)
|
||||||
|
})
|
||||||
|
|
||||||
// Print the list of services in a table format.
|
// Print the list of services in a table format.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
|
|||||||
@@ -11,31 +11,32 @@ import (
|
|||||||
|
|
||||||
type rmOptions struct {
|
type rmOptions struct {
|
||||||
services []string
|
services []string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRmCommand() *cobra.Command {
|
func NewRmCommand(groupID string) *cobra.Command {
|
||||||
opts := rmOptions{}
|
opts := rmOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm SERVICE [SERVICE...]",
|
Use: "rm SERVICE [SERVICE...]",
|
||||||
Aliases: []string{"remove", "delete"},
|
Aliases: []string{"remove", "delete"},
|
||||||
Short: "Remove one or more services.",
|
Short: "Remove one or more services.",
|
||||||
|
Long: `Remove one or more services.
|
||||||
|
|
||||||
|
The volumes used by the services are preserved and should be removed separately
|
||||||
|
with 'uc volume rm'. Anonymous Docker volumes (automatically created from VOLUME
|
||||||
|
directives in image Dockerfiles) are automatically removed with their containers.`,
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
opts.services = args
|
opts.services = args
|
||||||
return rm(cmd.Context(), uncli, opts)
|
return rm(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: groupID,
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,18 @@ func NewRootCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "service",
|
Use: "service",
|
||||||
Aliases: []string{"svc"},
|
Aliases: []string{"svc"},
|
||||||
Short: "Manage services in an Uncloud cluster.",
|
Short: "Manage services in the cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewInspectCommand(),
|
NewExecCommand(""),
|
||||||
NewListCommand(),
|
NewInspectCommand(""),
|
||||||
NewRmCommand(),
|
NewListCommand(""),
|
||||||
NewRunCommand(),
|
NewLogsCommand(""),
|
||||||
NewScaleCommand(),
|
NewRmCommand(""),
|
||||||
NewExecCommand(),
|
NewRunCommand(""),
|
||||||
|
NewScaleCommand(""),
|
||||||
|
NewStartCommand(""),
|
||||||
|
NewStopCommand(""),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,9 @@ type runOptions struct {
|
|||||||
replicas uint
|
replicas uint
|
||||||
user string
|
user string
|
||||||
volumes []string
|
volumes []string
|
||||||
|
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRunCommand() *cobra.Command {
|
func NewRunCommand(groupID string) *cobra.Command {
|
||||||
opts := runOptions{}
|
opts := runOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -56,6 +54,7 @@ func NewRunCommand() *cobra.Command {
|
|||||||
|
|
||||||
return run(cmd.Context(), uncli, opts)
|
return run(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: groupID,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
|
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
|
||||||
@@ -112,11 +111,6 @@ func NewRunCommand() *cobra.Command {
|
|||||||
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
|
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
|
||||||
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context to run the service in. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +120,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,9 @@ import (
|
|||||||
type scaleOptions struct {
|
type scaleOptions struct {
|
||||||
service string
|
service string
|
||||||
replicas uint
|
replicas uint
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewScaleCommand() *cobra.Command {
|
func NewScaleCommand(groupID string) *cobra.Command {
|
||||||
opts := scaleOptions{}
|
opts := scaleOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "scale SERVICE REPLICAS",
|
Use: "scale SERVICE REPLICAS",
|
||||||
@@ -37,13 +36,9 @@ func NewScaleCommand() *cobra.Command {
|
|||||||
|
|
||||||
return scale(cmd.Context(), uncli, opts)
|
return scale(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
|
GroupID: groupID,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +52,7 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//nolint:dupl
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type startOptions struct {
|
||||||
|
services []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStartCommand(groupID string) *cobra.Command {
|
||||||
|
opts := startOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "start SERVICE [SERVICE...]",
|
||||||
|
Short: "Start one or more services.",
|
||||||
|
Long: `Start one or more previously stopped services.
|
||||||
|
|
||||||
|
Starts all containers of the specified service(s) across all machines in the cluster.
|
||||||
|
Services can be specified by name or ID.`,
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
opts.services = args
|
||||||
|
return start(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
GroupID: groupID,
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(ctx context.Context, uncli *cli.CLI, opts startOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
for _, s := range opts.services {
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if err = client.StartService(ctx, s); err != nil {
|
||||||
|
return fmt.Errorf("start service '%s': %w", s, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), "Starting service "+s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//nolint:dupl
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stopOptions struct {
|
||||||
|
services []string
|
||||||
|
signal string
|
||||||
|
timeout int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStopCommand(groupID string) *cobra.Command {
|
||||||
|
opts := stopOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "stop SERVICE [SERVICE...]",
|
||||||
|
Short: "Stop one or more services.",
|
||||||
|
Long: `Stop one or more running services.
|
||||||
|
|
||||||
|
Gracefully stops all containers of the specified service(s) across all machines in the cluster.
|
||||||
|
Services can be specified by name or ID. Stopped services can be restarted with 'uc start'.`,
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
opts.services = args
|
||||||
|
return stop(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
GroupID: groupID,
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&opts.signal, "signal", "s", "",
|
||||||
|
"Signal to send to each container's main process.\n"+
|
||||||
|
"Can be a signal name (SIGTERM, SIGINT, SIGHUP, etc.) or a number. (default SIGTERM)")
|
||||||
|
cmd.Flags().IntVarP(&opts.timeout, "timeout", "t", 10,
|
||||||
|
"Seconds to wait for each container to stop gracefully before forcibly killing it with SIGKILL.\n"+
|
||||||
|
"Use -1 to wait indefinitely.")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop(ctx context.Context, uncli *cli.CLI, opts stopOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
stopOpts := container.StopOptions{
|
||||||
|
Signal: opts.signal,
|
||||||
|
Timeout: &opts.timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range opts.services {
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if err = client.StopService(ctx, s, stopOpts); err != nil {
|
||||||
|
return fmt.Errorf("stop service '%s': %w", s, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), "Stopping service "+s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ type createOptions struct {
|
|||||||
driverOpts []string
|
driverOpts []string
|
||||||
labels []string
|
labels []string
|
||||||
machine string
|
machine string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCreateCommand() *cobra.Command {
|
func NewCreateCommand() *cobra.Command {
|
||||||
@@ -50,14 +49,12 @@ func NewCreateCommand() *cobra.Command {
|
|||||||
"Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.")
|
"Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.")
|
||||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
"Name or ID of the machine to create the volume on.")
|
"Name or ID of the machine to create the volume on.")
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
|
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
machine string
|
machine string
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInspectCommand() *cobra.Command {
|
func NewInspectCommand() *cobra.Command {
|
||||||
@@ -32,14 +31,12 @@ func NewInspectCommand() *cobra.Command {
|
|||||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
"Name or ID of the machine where the volume is located. "+
|
"Name or ID of the machine where the volume is located. "+
|
||||||
"If not specified, the volume will be searched across all machines.")
|
"If not specified, the volume will be searched across all machines.")
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
|
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
type listOptions struct {
|
type listOptions struct {
|
||||||
machines []string
|
machines []string
|
||||||
quiet bool
|
quiet bool
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
func NewListCommand() *cobra.Command {
|
||||||
@@ -38,14 +37,11 @@ func NewListCommand() *cobra.Command {
|
|||||||
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
|
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
|
||||||
"Only display volume names.")
|
"Only display volume names.")
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ type removeOptions struct {
|
|||||||
force bool
|
force bool
|
||||||
machines []string
|
machines []string
|
||||||
yes bool
|
yes bool
|
||||||
context string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRemoveCommand() *cobra.Command {
|
func NewRemoveCommand() *cobra.Command {
|
||||||
@@ -41,14 +40,11 @@ func NewRemoveCommand() *cobra.Command {
|
|||||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
"Do not prompt for confirmation before removing the volume(s).")
|
"Do not prompt for confirmation before removing the volume(s).")
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
|
||||||
"Name of the cluster context. (default is the current context)")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
|
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
|
||||||
client, err := uncli.ConnectCluster(ctx, opts.context)
|
client, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
func NewRootCommand() *cobra.Command {
|
func NewRootCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "volume",
|
Use: "volume",
|
||||||
Short: "Manage volumes in an Uncloud cluster.",
|
Short: "Manage volumes in the cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewCreateCommand(),
|
NewCreateCommand(),
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ require (
|
|||||||
github.com/alecthomas/chroma/v2 v2.20.0
|
github.com/alecthomas/chroma/v2 v2.20.0
|
||||||
github.com/caddyserver/caddy/v2 v2.8.4
|
github.com/caddyserver/caddy/v2 v2.8.4
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
github.com/charmbracelet/bubbles v0.20.0
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.9
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/huh v0.6.0
|
github.com/charmbracelet/huh v0.6.0
|
||||||
|
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/compose-spec/compose-go/v2 v2.9.0
|
github.com/compose-spec/compose-go/v2 v2.9.0
|
||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
@@ -45,7 +46,7 @@ require (
|
|||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/opencontainers/go-digest v1.0.0
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/psviderski/unregistry v0.3.1
|
github.com/psviderski/unregistry v0.4.1
|
||||||
github.com/siderolabs/discovery-api v0.1.4
|
github.com/siderolabs/discovery-api v0.1.4
|
||||||
github.com/siderolabs/discovery-client v0.1.9
|
github.com/siderolabs/discovery-client v0.1.9
|
||||||
github.com/siderolabs/grpc-proxy v0.5.1
|
github.com/siderolabs/grpc-proxy v0.5.1
|
||||||
@@ -149,7 +150,7 @@ require (
|
|||||||
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect
|
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fatih/color v1.17.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsevents v0.2.0 // indirect
|
github.com/fsnotify/fsevents v0.2.0 // indirect
|
||||||
github.com/fvbommel/sortorder v1.1.0 // indirect
|
github.com/fvbommel/sortorder v1.1.0 // indirect
|
||||||
|
|||||||
@@ -168,22 +168,24 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
|||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
github.com/charmbracelet/bubbletea v1.3.9 h1:OBYdfRo6QnlIcXNmcoI2n1NNS65Nk6kI2L2FO1puS/4=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.9/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
|
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
|
||||||
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
|
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
|
||||||
|
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b h1:oefmPctgff7OB4cDz3Ndp+ewwrh7i+fwth70qvZCieI=
|
||||||
|
github.com/charmbracelet/huh/spinner v0.0.0-20251110114415-25888d17260b/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8=
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
@@ -344,8 +346,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97
|
|||||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
|
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
|
||||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||||
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
|
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
|
||||||
@@ -1038,8 +1040,8 @@ github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDa
|
|||||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
github.com/psviderski/unregistry v0.3.1 h1:2xQ3uMNMyXAASqq9+kGUgNMIFOvPSMIFGdz47uDfjhQ=
|
github.com/psviderski/unregistry v0.4.1 h1:KedQTjyj8hHbzygK9uw5YS10amMZ5cjSYHqSjUUBhoE=
|
||||||
github.com/psviderski/unregistry v0.3.1/go.mod h1:DXLnzrskO5NuRlzZhpbyo1E6KpirnctUvq6WtCGkpUQ=
|
github.com/psviderski/unregistry v0.4.1/go.mod h1:DXLnzrskO5NuRlzZhpbyo1E6KpirnctUvq6WtCGkpUQ=
|
||||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
|
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ type BuildServicesOptions struct {
|
|||||||
PushRegistry bool
|
PushRegistry bool
|
||||||
|
|
||||||
// Cluster-specific options (only used if PushCluster is true).
|
// Cluster-specific options (only used if PushCluster is true).
|
||||||
// Context is the name of the cluster context.
|
|
||||||
Context string
|
|
||||||
// Machines is a list of machine names or IDs to push the image to. If empty, images are pushed to all machines.
|
// Machines is a list of machine names or IDs to push the image to. If empty, images are pushed to all machines.
|
||||||
Machines []string
|
Machines []string
|
||||||
}
|
}
|
||||||
@@ -95,7 +93,7 @@ func (cli *CLI) BuildServices(ctx context.Context, project *composetypes.Project
|
|||||||
// Add a line break after the build output.
|
// Add a line break after the build output.
|
||||||
fmt.Fprintln(cli.ProgressOut())
|
fmt.Fprintln(cli.ProgressOut())
|
||||||
|
|
||||||
clusterClient, err := cli.ConnectCluster(ctx, opts.Context)
|
clusterClient, err := cli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-17
@@ -30,12 +30,14 @@ const (
|
|||||||
type CLI struct {
|
type CLI struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
conn *config.MachineConnection
|
conn *config.MachineConnection
|
||||||
|
contextOverride string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new CLI instance with the given config path or remote machine connection.
|
// New creates a new CLI instance with the given config path or remote machine connection.
|
||||||
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
|
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
|
||||||
// a cluster without creating a config.
|
// a cluster without creating a config.
|
||||||
func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
|
// If a non-empty context name is given, it will override the current default.
|
||||||
|
func New(configPath string, conn *config.MachineConnection, contextName string) (*CLI, error) {
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
return &CLI{conn: conn}, nil
|
return &CLI{conn: conn}, nil
|
||||||
}
|
}
|
||||||
@@ -47,6 +49,7 @@ func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
|
|||||||
|
|
||||||
return &CLI{
|
return &CLI{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
|
contextOverride: contextName,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,19 +71,28 @@ func (cli *CLI) SetCurrentContext(name string) error {
|
|||||||
return cli.Config.Save()
|
return cli.Config.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectCluster connects to a cluster using the given context name or the current context if not specified.
|
func (cli *CLI) GetContextOverrideOrCurrent() string {
|
||||||
|
contextName := cli.contextOverride
|
||||||
|
if contextName == "" {
|
||||||
|
contextName = cli.Config.CurrentContext
|
||||||
|
}
|
||||||
|
return contextName
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectCluster connects to a cluster using the context override or the current context if not specified.
|
||||||
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||||
func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client.Client, error) {
|
func (cli *CLI) ConnectCluster(ctx context.Context) (*client.Client, error) {
|
||||||
return cli.ConnectClusterWithOptions(ctx, contextName, ConnectOptions{
|
return cli.ConnectClusterWithOptions(ctx, ConnectOptions{
|
||||||
// Default to showing progress for CLI usage.
|
// Default to showing progress for CLI usage.
|
||||||
ShowProgress: true,
|
ShowProgress: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectClusterWithOptions connects to a cluster using the given context name and options.
|
// ConnectClusterWithOptions connects to a cluster with the given options.
|
||||||
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||||
|
// If the CLI has an override context, it is used instead of the current default.
|
||||||
// Options are useful when using the CLI as a library where you may want to disable visual feedback.
|
// Options are useful when using the CLI as a library where you may want to disable visual feedback.
|
||||||
func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, contextName string, opts ConnectOptions) (*client.Client, error) {
|
func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, opts ConnectOptions) (*client.Client, error) {
|
||||||
if cli.conn != nil {
|
if cli.conn != nil {
|
||||||
return ConnectCluster(ctx, *cli.conn, opts)
|
return ConnectCluster(ctx, *cli.conn, opts)
|
||||||
}
|
}
|
||||||
@@ -92,6 +104,8 @@ func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, contextName strin
|
|||||||
cli.Config.Path(),
|
cli.Config.Path(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contextName := cli.contextOverride
|
||||||
if contextName == "" {
|
if contextName == "" {
|
||||||
// If the cluster is not specified, use the current cluster if set.
|
// If the cluster is not specified, use the current cluster if set.
|
||||||
if cli.Config.CurrentContext == "" {
|
if cli.Config.CurrentContext == "" {
|
||||||
@@ -157,7 +171,7 @@ func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*clie
|
|||||||
return cli.initRemoteMachine(ctx, opts)
|
return cli.initRemoteMachine(ctx, opts)
|
||||||
}
|
}
|
||||||
// TODO: implement local machine initialisation
|
// TODO: implement local machine initialisation
|
||||||
return nil, fmt.Errorf("local machine initialisation is not implemented yet")
|
return nil, fmt.Errorf("local machine initialisation is not implemented yet. Please specify a remote machine")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||||
@@ -228,8 +242,21 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
|||||||
|
|
||||||
// Save the machine's SSH connection details in the context config.
|
// Save the machine's SSH connection details in the context config.
|
||||||
connCfg := config.MachineConnection{
|
connCfg := config.MachineConnection{
|
||||||
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
|
||||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||||
|
MachineID: resp.Machine.Id,
|
||||||
|
}
|
||||||
|
if opts.RemoteMachine.UseSSHCLI {
|
||||||
|
connCfg.SSHCLI = config.NewSSHDestination(
|
||||||
|
opts.RemoteMachine.User,
|
||||||
|
opts.RemoteMachine.Host,
|
||||||
|
opts.RemoteMachine.Port,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
connCfg.SSH = config.NewSSHDestination(
|
||||||
|
opts.RemoteMachine.User,
|
||||||
|
opts.RemoteMachine.Host,
|
||||||
|
opts.RemoteMachine.Port,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||||
if err = cli.Config.Save(); err != nil {
|
if err = cli.Config.Save(); err != nil {
|
||||||
@@ -265,7 +292,6 @@ func (cli *CLI) newContextName(name string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AddMachineOptions struct {
|
type AddMachineOptions struct {
|
||||||
Context string
|
|
||||||
MachineName string
|
MachineName string
|
||||||
PublicIP *netip.Addr
|
PublicIP *netip.Addr
|
||||||
RemoteMachine *RemoteMachine
|
RemoteMachine *RemoteMachine
|
||||||
@@ -278,11 +304,8 @@ type AddMachineOptions struct {
|
|||||||
// cluster. The machine client is connected to the new machine and can be used to interact with it.
|
// cluster. The machine client is connected to the new machine and can be used to interact with it.
|
||||||
// Both client should be closed after use by the caller.
|
// Both client should be closed after use by the caller.
|
||||||
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) {
|
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) {
|
||||||
contextName := opts.Context
|
contextName := cli.GetContextOverrideOrCurrent()
|
||||||
if contextName == "" {
|
c, err := cli.ConnectCluster(ctx)
|
||||||
contextName = cli.Config.CurrentContext
|
|
||||||
}
|
|
||||||
c, err := cli.ConnectCluster(ctx, contextName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||||
}
|
}
|
||||||
@@ -396,11 +419,21 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
|
|
||||||
// Save the machine's SSH connection details in the context config.
|
// Save the machine's SSH connection details in the context config.
|
||||||
connCfg := config.MachineConnection{
|
connCfg := config.MachineConnection{
|
||||||
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
|
||||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||||
|
MachineID: addResp.Machine.Id,
|
||||||
}
|
}
|
||||||
if contextName == "" {
|
if opts.RemoteMachine.UseSSHCLI {
|
||||||
contextName = cli.Config.CurrentContext
|
connCfg.SSHCLI = config.NewSSHDestination(
|
||||||
|
opts.RemoteMachine.User,
|
||||||
|
opts.RemoteMachine.Host,
|
||||||
|
opts.RemoteMachine.Port,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
connCfg.SSH = config.NewSSHDestination(
|
||||||
|
opts.RemoteMachine.User,
|
||||||
|
opts.RemoteMachine.Host,
|
||||||
|
opts.RemoteMachine.Port,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||||
if err = cli.Config.Save(); err != nil {
|
if err = cli.Config.Save(); err != nil {
|
||||||
@@ -420,6 +453,35 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
func provisionOrConnectRemoteMachine(
|
func provisionOrConnectRemoteMachine(
|
||||||
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
|
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
|
||||||
) (*client.Client, error) {
|
) (*client.Client, error) {
|
||||||
|
// Use SSH CLI
|
||||||
|
if remoteMachine.UseSSHCLI {
|
||||||
|
exec := sshexec.NewSSHCLIRemote(
|
||||||
|
remoteMachine.User,
|
||||||
|
remoteMachine.Host,
|
||||||
|
remoteMachine.Port,
|
||||||
|
remoteMachine.KeyPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !skipInstall {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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)
|
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 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 == "" {
|
if err != nil && remoteMachine.KeyPath == "" {
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ type MachineConnection struct {
|
|||||||
// TCP is the address and port of the machine's API server.
|
// 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.
|
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
|
||||||
TCP *netip.AddrPort `yaml:"tcp,omitempty"`
|
TCP *netip.AddrPort `yaml:"tcp,omitempty"`
|
||||||
|
// Unix is the path to the machine's API unix socket.
|
||||||
|
Unix string `yaml:"unix,omitempty"`
|
||||||
Host string `yaml:"host,omitempty"`
|
Host string `yaml:"host,omitempty"`
|
||||||
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
||||||
|
MachineID string `yaml:"machine_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c MachineConnection) String() string {
|
func (c MachineConnection) String() string {
|
||||||
@@ -34,6 +37,8 @@ func (c MachineConnection) String() string {
|
|||||||
return "ssh+cli://" + string(c.SSHCLI)
|
return "ssh+cli://" + string(c.SSHCLI)
|
||||||
} else if c.TCP != nil && c.TCP.IsValid() {
|
} else if c.TCP != nil && c.TCP.IsValid() {
|
||||||
return fmt.Sprintf("tcp://%s", c.TCP)
|
return fmt.Sprintf("tcp://%s", c.TCP)
|
||||||
|
} else if c.Unix != "" {
|
||||||
|
return fmt.Sprintf("unix://%s", c.Unix)
|
||||||
}
|
}
|
||||||
return "unknown connection"
|
return "unknown connection"
|
||||||
}
|
}
|
||||||
@@ -49,12 +54,15 @@ func (c *MachineConnection) Validate() error {
|
|||||||
if c.TCP != nil && c.TCP.IsValid() {
|
if c.TCP != nil && c.TCP.IsValid() {
|
||||||
setCount++
|
setCount++
|
||||||
}
|
}
|
||||||
|
if c.Unix != "" {
|
||||||
|
setCount++
|
||||||
|
}
|
||||||
|
|
||||||
if setCount == 0 {
|
if setCount == 0 {
|
||||||
return errors.New("no connection method specified (ssh, ssh_cli, or tcp required)")
|
return errors.New("no connection method specified (ssh, ssh_cli, tcp, or unix required)")
|
||||||
}
|
}
|
||||||
if setCount > 1 {
|
if setCount > 1 {
|
||||||
return errors.New("only one connection method allowed per connection (ssh, ssh_cli, or tcp)")
|
return errors.New("only one connection method allowed per connection (ssh, ssh_cli, tcp, or unix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -53,11 +53,27 @@ func TestMachineConnection_String(t *testing.T) {
|
|||||||
},
|
},
|
||||||
want: "tcp://10.0.0.1:8080",
|
want: "tcp://10.0.0.1:8080",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "unix connection",
|
||||||
|
conn: MachineConnection{
|
||||||
|
Unix: "/run/uncloud/uncloud.sock",
|
||||||
|
},
|
||||||
|
want: "unix:///run/uncloud/uncloud.sock",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "no connection",
|
name: "no connection",
|
||||||
conn: MachineConnection{},
|
conn: MachineConnection{},
|
||||||
want: "unknown connection",
|
want: "unknown connection",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "ssh connection with machine id",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host.com",
|
||||||
|
MachineID: "ed98b9f7575308c340263cd279e3b568",
|
||||||
|
},
|
||||||
|
// String() does not use MachineID; just verifying entry with it is valid
|
||||||
|
want: "ssh://user@host.com",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -103,6 +119,13 @@ func TestMachineConnection_Validate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "unix only - valid",
|
||||||
|
conn: MachineConnection{
|
||||||
|
Unix: "/path/to/socket",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "no connection method - error",
|
name: "no connection method - error",
|
||||||
conn: MachineConnection{},
|
conn: MachineConnection{},
|
||||||
@@ -118,6 +141,15 @@ func TestMachineConnection_Validate(t *testing.T) {
|
|||||||
wantErr: true,
|
wantErr: true,
|
||||||
errMsg: "only one connection method allowed",
|
errMsg: "only one connection method allowed",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "ssh and unix - error",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host",
|
||||||
|
Unix: "/path/to/socket",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "only one connection method allowed",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "ssh and tcp - error",
|
name: "ssh and tcp - error",
|
||||||
conn: MachineConnection{
|
conn: MachineConnection{
|
||||||
|
|||||||
@@ -4,3 +4,21 @@ type Context struct {
|
|||||||
Name string `yaml:"-"`
|
Name string `yaml:"-"`
|
||||||
Connections []MachineConnection `yaml:"connections"`
|
Connections []MachineConnection `yaml:"connections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Context) SetDefaultConnection(index int) {
|
||||||
|
// Do nothing if the index is out of range.
|
||||||
|
if index < 0 || index >= len(c.Connections) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := c.Connections[index]
|
||||||
|
newConnections := make([]MachineConnection, 0, len(c.Connections))
|
||||||
|
newConnections = append(newConnections, selected)
|
||||||
|
|
||||||
|
for i, conn := range c.Connections {
|
||||||
|
if i != index {
|
||||||
|
newConnections = append(newConnections, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Connections = newConnections
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ func connectCluster(ctx context.Context, conn config.MachineConnection) (*client
|
|||||||
useSSHCLI = true
|
useSSHCLI = true
|
||||||
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||||
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||||
|
} else if conn.Unix != "" {
|
||||||
|
return client.New(ctx, connector.NewUnixConnector(conn.Unix))
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.New("connection configuration is invalid")
|
return nil, errors.New("connection configuration is invalid")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type RemoteMachine struct {
|
|||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
KeyPath string
|
KeyPath string
|
||||||
|
UseSSHCLI bool // indicates ssh+cli:// should be used
|
||||||
}
|
}
|
||||||
|
|
||||||
func installCmd(user string, version string) string {
|
func installCmd(user string, version string) string {
|
||||||
|
|||||||
+2037
-1776
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ package api;
|
|||||||
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
||||||
|
|
||||||
import "google/protobuf/empty.proto";
|
import "google/protobuf/empty.proto";
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
import "internal/machine/api/pb/common.proto";
|
import "internal/machine/api/pb/common.proto";
|
||||||
|
|
||||||
service Docker {
|
service Docker {
|
||||||
@@ -15,6 +16,9 @@ service Docker {
|
|||||||
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
|
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
|
||||||
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
rpc RemoveContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||||
|
|
||||||
|
rpc ExecContainer(stream ExecContainerRequest) returns (stream ExecContainerResponse);
|
||||||
|
rpc ContainerLogs(ContainerLogsRequest) returns (stream ContainerLogEntry);
|
||||||
|
|
||||||
rpc PullImage(PullImageRequest) returns (stream JSONMessage);
|
rpc PullImage(PullImageRequest) returns (stream JSONMessage);
|
||||||
rpc InspectImage(InspectImageRequest) returns (InspectImageResponse);
|
rpc InspectImage(InspectImageRequest) returns (InspectImageResponse);
|
||||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
@@ -30,8 +34,6 @@ service Docker {
|
|||||||
rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer);
|
rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer);
|
||||||
rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse);
|
rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse);
|
||||||
rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||||
|
|
||||||
rpc ExecContainer(stream ExecContainerRequest) returns (stream ExecContainerResponse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateContainerRequest {
|
message CreateContainerRequest {
|
||||||
@@ -94,6 +96,64 @@ message RemoveContainerRequest {
|
|||||||
bytes options = 2;
|
bytes options = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ExecContainerRequest {
|
||||||
|
oneof payload {
|
||||||
|
// Initial configuration for the exec session. Must be sent as the first message.
|
||||||
|
ExecConfig config = 1;
|
||||||
|
// Raw stdin data to be written to the exec process.
|
||||||
|
bytes stdin = 2;
|
||||||
|
// TTY resize event (only used when TTY is enabled).
|
||||||
|
ResizeEvent resize = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExecConfig {
|
||||||
|
// Container ID to execute the command in.
|
||||||
|
string container_id = 1;
|
||||||
|
// JSON serialised ExecOptions
|
||||||
|
bytes options = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ResizeEvent {
|
||||||
|
uint32 height = 1;
|
||||||
|
uint32 width = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExecContainerResponse {
|
||||||
|
oneof payload {
|
||||||
|
// Exec instance ID returned after creating the exec.
|
||||||
|
string exec_id = 1;
|
||||||
|
// Raw stdout data from the exec process.
|
||||||
|
bytes stdout = 2;
|
||||||
|
// Raw stderr data from the exec process (only when TTY is disabled).
|
||||||
|
bytes stderr = 3;
|
||||||
|
// Exit code of the exec process. Sent as the final message.
|
||||||
|
int32 exit_code = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
message PullImageRequest {
|
||||||
string image = 1;
|
string image = 1;
|
||||||
// JSON serialised image.PullOptions.
|
// JSON serialised image.PullOptions.
|
||||||
@@ -216,39 +276,3 @@ message MachineServiceContainers {
|
|||||||
Metadata metadata = 1;
|
Metadata metadata = 1;
|
||||||
repeated ServiceContainer containers = 2;
|
repeated ServiceContainer containers = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ExecContainerRequest {
|
|
||||||
oneof payload {
|
|
||||||
// Initial configuration for the exec session. Must be sent as the first message.
|
|
||||||
ExecConfig config = 1;
|
|
||||||
// Raw stdin data to be written to the exec process.
|
|
||||||
bytes stdin = 2;
|
|
||||||
// TTY resize event (only used when TTY is enabled).
|
|
||||||
ResizeEvent resize = 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
message ExecConfig {
|
|
||||||
// Container ID to execute the command in.
|
|
||||||
string container_id = 1;
|
|
||||||
// JSON serialised ExecOptions
|
|
||||||
bytes options = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ResizeEvent {
|
|
||||||
uint32 height = 1;
|
|
||||||
uint32 width = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ExecContainerResponse {
|
|
||||||
oneof payload {
|
|
||||||
// Exec instance ID returned after creating the exec.
|
|
||||||
string exec_id = 1;
|
|
||||||
// Raw stdout data from the exec process.
|
|
||||||
bytes stdout = 2;
|
|
||||||
// Raw stderr data from the exec process (only when TTY is disabled).
|
|
||||||
bytes stderr = 3;
|
|
||||||
// Exit code of the exec process. Sent as the final message.
|
|
||||||
int32 exit_code = 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const (
|
|||||||
Docker_StopContainer_FullMethodName = "/api.Docker/StopContainer"
|
Docker_StopContainer_FullMethodName = "/api.Docker/StopContainer"
|
||||||
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
|
Docker_ListContainers_FullMethodName = "/api.Docker/ListContainers"
|
||||||
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
|
Docker_RemoveContainer_FullMethodName = "/api.Docker/RemoveContainer"
|
||||||
|
Docker_ExecContainer_FullMethodName = "/api.Docker/ExecContainer"
|
||||||
|
Docker_ContainerLogs_FullMethodName = "/api.Docker/ContainerLogs"
|
||||||
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
||||||
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
||||||
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
||||||
@@ -37,7 +39,6 @@ const (
|
|||||||
Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer"
|
Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer"
|
||||||
Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers"
|
Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers"
|
||||||
Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer"
|
Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer"
|
||||||
Docker_ExecContainer_FullMethodName = "/api.Docker/ExecContainer"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DockerClient is the client API for Docker service.
|
// DockerClient is the client API for Docker service.
|
||||||
@@ -50,6 +51,8 @@ type DockerClient interface {
|
|||||||
StopContainer(ctx context.Context, in *StopContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
StopContainer(ctx context.Context, in *StopContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error)
|
ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ListContainersResponse, error)
|
||||||
RemoveContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, 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)
|
||||||
PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], 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)
|
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
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
@@ -63,7 +66,6 @@ type DockerClient interface {
|
|||||||
InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error)
|
InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error)
|
||||||
ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error)
|
ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error)
|
||||||
RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type dockerClient struct {
|
type dockerClient struct {
|
||||||
@@ -134,9 +136,41 @@ func (c *dockerClient) RemoveContainer(ctx context.Context, in *RemoveContainerR
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[0], Docker_ExecContainer_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[ExecContainerRequest, ExecContainerResponse]{ClientStream: stream}
|
||||||
|
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 Docker_ExecContainerClient = grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse]
|
||||||
|
|
||||||
|
func (c *dockerClient) ContainerLogs(ctx context.Context, in *ContainerLogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ContainerLogEntry], 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}
|
||||||
|
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 Docker_ContainerLogsClient = grpc.ServerStreamingClient[ContainerLogEntry]
|
||||||
|
|
||||||
func (c *dockerClient) PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error) {
|
func (c *dockerClient) PullImage(ctx context.Context, in *PullImageRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[JSONMessage], error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[0], Docker_PullImage_FullMethodName, cOpts...)
|
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[2], Docker_PullImage_FullMethodName, cOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -253,19 +287,6 @@ func (c *dockerClient) RemoveServiceContainer(ctx context.Context, in *RemoveCon
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *dockerClient) ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[1], Docker_ExecContainer_FullMethodName, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
x := &grpc.GenericClientStream[ExecContainerRequest, ExecContainerResponse]{ClientStream: stream}
|
|
||||||
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 Docker_ExecContainerClient = grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse]
|
|
||||||
|
|
||||||
// DockerServer is the server API for Docker service.
|
// DockerServer is the server API for Docker service.
|
||||||
// All implementations must embed UnimplementedDockerServer
|
// All implementations must embed UnimplementedDockerServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -276,6 +297,8 @@ type DockerServer interface {
|
|||||||
StopContainer(context.Context, *StopContainerRequest) (*emptypb.Empty, error)
|
StopContainer(context.Context, *StopContainerRequest) (*emptypb.Empty, error)
|
||||||
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
|
ListContainers(context.Context, *ListContainersRequest) (*ListContainersResponse, error)
|
||||||
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
||||||
|
ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error
|
||||||
|
ContainerLogs(*ContainerLogsRequest, grpc.ServerStreamingServer[ContainerLogEntry]) error
|
||||||
PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error
|
PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error
|
||||||
InspectImage(context.Context, *InspectImageRequest) (*InspectImageResponse, error)
|
InspectImage(context.Context, *InspectImageRequest) (*InspectImageResponse, error)
|
||||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
@@ -289,7 +312,6 @@ type DockerServer interface {
|
|||||||
InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error)
|
InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error)
|
||||||
ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error)
|
ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error)
|
||||||
RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
||||||
ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error
|
|
||||||
mustEmbedUnimplementedDockerServer()
|
mustEmbedUnimplementedDockerServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +340,12 @@ func (UnimplementedDockerServer) ListContainers(context.Context, *ListContainers
|
|||||||
func (UnimplementedDockerServer) RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
|
func (UnimplementedDockerServer) RemoveContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method RemoveContainer not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveContainer not implemented")
|
||||||
}
|
}
|
||||||
|
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 {
|
||||||
|
return status.Errorf(codes.Unimplemented, "method ContainerLogs not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
|
func (UnimplementedDockerServer) PullImage(*PullImageRequest, grpc.ServerStreamingServer[JSONMessage]) error {
|
||||||
return status.Errorf(codes.Unimplemented, "method PullImage not implemented")
|
return status.Errorf(codes.Unimplemented, "method PullImage not implemented")
|
||||||
}
|
}
|
||||||
@@ -351,9 +379,6 @@ func (UnimplementedDockerServer) ListServiceContainers(context.Context, *ListSer
|
|||||||
func (UnimplementedDockerServer) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
|
func (UnimplementedDockerServer) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method RemoveServiceContainer not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveServiceContainer not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedDockerServer) ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error {
|
|
||||||
return status.Errorf(codes.Unimplemented, "method ExecContainer not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedDockerServer) mustEmbedUnimplementedDockerServer() {}
|
func (UnimplementedDockerServer) mustEmbedUnimplementedDockerServer() {}
|
||||||
func (UnimplementedDockerServer) testEmbeddedByValue() {}
|
func (UnimplementedDockerServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -483,6 +508,24 @@ func _Docker_RemoveContainer_Handler(srv interface{}, ctx context.Context, dec f
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _Docker_ExecContainer_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
return srv.(DockerServer).ExecContainer(&grpc.GenericServerStream[ExecContainerRequest, ExecContainerResponse]{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_ExecContainerServer = grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]
|
||||||
|
|
||||||
|
func _Docker_ContainerLogs_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
m := new(ContainerLogsRequest)
|
||||||
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return srv.(DockerServer).ContainerLogs(m, &grpc.GenericServerStream[ContainerLogsRequest, ContainerLogEntry]{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]
|
||||||
|
|
||||||
func _Docker_PullImage_Handler(srv interface{}, stream grpc.ServerStream) error {
|
func _Docker_PullImage_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
m := new(PullImageRequest)
|
m := new(PullImageRequest)
|
||||||
if err := stream.RecvMsg(m); err != nil {
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
@@ -674,13 +717,6 @@ func _Docker_RemoveServiceContainer_Handler(srv interface{}, ctx context.Context
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func _Docker_ExecContainer_Handler(srv interface{}, stream grpc.ServerStream) error {
|
|
||||||
return srv.(DockerServer).ExecContainer(&grpc.GenericServerStream[ExecContainerRequest, ExecContainerResponse]{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_ExecContainerServer = grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]
|
|
||||||
|
|
||||||
// Docker_ServiceDesc is the grpc.ServiceDesc for Docker service.
|
// Docker_ServiceDesc is the grpc.ServiceDesc for Docker service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -754,17 +790,22 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{
|
Streams: []grpc.StreamDesc{
|
||||||
{
|
|
||||||
StreamName: "PullImage",
|
|
||||||
Handler: _Docker_PullImage_Handler,
|
|
||||||
ServerStreams: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
StreamName: "ExecContainer",
|
StreamName: "ExecContainer",
|
||||||
Handler: _Docker_ExecContainer_Handler,
|
Handler: _Docker_ExecContainer_Handler,
|
||||||
ServerStreams: true,
|
ServerStreams: true,
|
||||||
ClientStreams: true,
|
ClientStreams: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
StreamName: "ContainerLogs",
|
||||||
|
Handler: _Docker_ContainerLogs_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
StreamName: "PullImage",
|
||||||
|
Handler: _Docker_PullImage_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Metadata: "internal/machine/api/pb/docker.proto",
|
Metadata: "internal/machine/api/pb/docker.proto",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,10 +201,10 @@ func handleClientOutputStream(ctx context.Context, stream pb.Docker_ExecContaine
|
|||||||
// ExecContainer executes a command in a running container with bidirectional streaming.
|
// ExecContainer executes a command in a running container with bidirectional streaming.
|
||||||
// TODO: This can be merged with pkg/client as it's an unnecessary logic split.
|
// TODO: This can be merged with pkg/client as it's an unnecessary logic split.
|
||||||
func (c *Client) ExecContainer(ctx context.Context, opts ExecConfig) (exitCode int, err error) {
|
func (c *Client) ExecContainer(ctx context.Context, opts ExecConfig) (exitCode int, err error) {
|
||||||
// TODO: We need to handle Ctrl-C and other signals here to forward them to the container process.
|
// Note: In non-interactive mode (without TTY), signals like SIGTERM/SIGINT will terminate the client
|
||||||
// Right now, Ctrl-C will just terminate the client process, which is not ideal.
|
// process without being forwarded to the remote container process. We could catch and forward these
|
||||||
// We should catch the signal, send it to the container process, and only exit
|
// signals (useful for long-running commands), but for example "kubectl exec" doesn't do this either (as of November 2025),
|
||||||
// when the container process exits.
|
// so we keep it simple for now.
|
||||||
|
|
||||||
slog.Debug("starting ExecContainer", "containerID", opts.ContainerID, "options", opts.Options)
|
slog.Debug("starting ExecContainer", "containerID", opts.ContainerID, "options", opts.Options)
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||||
@@ -1015,6 +1016,97 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// logsHeartbeatInterval is the interval at which heartbeat entries are sent when there are no logs to stream.
|
||||||
|
const logsHeartbeatInterval = 200 * time.Millisecond
|
||||||
|
|
||||||
|
// ContainerLogs streams logs from a container.
|
||||||
|
func (s *Server) ContainerLogs(
|
||||||
|
req *pb.ContainerLogsRequest, stream grpc.ServerStreamingServer[pb.ContainerLogEntry],
|
||||||
|
) 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
logsCh, err := s.service.ContainerLogs(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
if errdefs.IsNotFound(err) {
|
||||||
|
return status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
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.Debug("Starting container 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.ContainerLogEntry{
|
||||||
|
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.ContainerLogEntry{
|
||||||
|
Stream: pb.ContainerLogEntry_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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// receiveExecConfig receives and validates the initial exec configuration from the stream.
|
// receiveExecConfig receives and validates the initial exec configuration from the stream.
|
||||||
func (s *Server) receiveExecConfig(stream pb.Docker_ExecContainerServer) (*pb.ExecConfig, api.ExecOptions, error) {
|
func (s *Server) receiveExecConfig(stream pb.Docker_ExecContainerServer) (*pb.ExecConfig, api.ExecOptions, error) {
|
||||||
req, err := stream.Recv()
|
req, err := stream.Recv()
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
package docker
|
package docker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
@@ -150,3 +154,104 @@ func (s *Service) ListImages(ctx context.Context, opts image.ListOptions) (Image
|
|||||||
|
|
||||||
return imagesResp, nil
|
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) {
|
||||||
|
dockerOpts := container.LogsOptions{
|
||||||
|
ShowStdout: true,
|
||||||
|
ShowStderr: true,
|
||||||
|
Follow: opts.Follow,
|
||||||
|
Tail: strconv.FormatInt(int64(opts.Tail), 10),
|
||||||
|
Since: opts.Since,
|
||||||
|
Until: opts.Until,
|
||||||
|
Timestamps: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := s.Client.ContainerLogs(ctx, opts.ContainerID, dockerOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
outCh := make(chan api.ContainerLogEntry)
|
||||||
|
stdoutWriter := &logsChannelWriter{ctx: ctx, ch: outCh, isStderr: false}
|
||||||
|
stderrWriter := &logsChannelWriter{ctx: ctx, ch: outCh, isStderr: true}
|
||||||
|
|
||||||
|
// Wrap the context in a cancellable one to unblock the second goroutine below when StdCopy completes.
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
// Run StdCopy in a goroutine to be able to handle context cancellation.
|
||||||
|
go func() {
|
||||||
|
defer close(outCh)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// StdCopy is blocking and will return when the reader is closed in another goroutine below or on error.
|
||||||
|
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 <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Close the reader when the context is done to cancel StdCopy if it's still running.
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
reader.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return outCh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
isStderr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *logsChannelWriter) Write(data []byte) (n int, err error) {
|
||||||
|
// Parse timestamp and message from the demultiplexed Docker log payload if the data looks like it contains one.
|
||||||
|
// Format: 2025-01-01T00:00:00.000000000Z message
|
||||||
|
timestamp := time.Time{}
|
||||||
|
message := data
|
||||||
|
if len(data) > 30 && data[4] == '-' && data[7] == '-' && data[10] == 'T' {
|
||||||
|
timestampPart, messagePart, found := bytes.Cut(data, []byte(" "))
|
||||||
|
if found {
|
||||||
|
timestamp, err = time.Parse(time.RFC3339Nano, string(timestampPart))
|
||||||
|
if err != nil {
|
||||||
|
timestamp = time.Time{}
|
||||||
|
}
|
||||||
|
message = messagePart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := api.ContainerLogEntry{
|
||||||
|
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.
|
||||||
|
Message: bytes.Clone(message),
|
||||||
|
}
|
||||||
|
if w.isStderr {
|
||||||
|
entry.Stream = api.LogStreamStderr
|
||||||
|
} else {
|
||||||
|
entry.Stream = api.LogStreamStdout
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case w.ch <- entry:
|
||||||
|
return len(data), nil
|
||||||
|
case <-w.ctx.Done():
|
||||||
|
return 0, w.ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package sshexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SSHCLIRemote struct {
|
||||||
|
user string
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
keyPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSSHCLIRemote(user, host string, port int, keyPath string) *SSHCLIRemote {
|
||||||
|
return &SSHCLIRemote{
|
||||||
|
user: user,
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
keyPath: keyPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Refactor and reuse this with buildDialArgs and buildSSHArgs from
|
||||||
|
// SSHCLI Connector.
|
||||||
|
func (r *SSHCLIRemote) buildSSHArgs() []string {
|
||||||
|
args := []string{"-o", "ConnectTimeout=5"}
|
||||||
|
|
||||||
|
if r.port != 0 && r.port != 22 {
|
||||||
|
args = append(args, "-p", strconv.Itoa(r.port))
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.keyPath != "" {
|
||||||
|
args = append(args, "-i", r.keyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, r.user+"@"+r.host)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHCLIRemote) Run(ctx context.Context, cmd string) (string, error) {
|
||||||
|
args := r.buildSSHArgs()
|
||||||
|
args = append(args, cmd)
|
||||||
|
|
||||||
|
execCmd := exec.CommandContext(ctx, "ssh", args...)
|
||||||
|
output, err := execCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimSpace(string(output)),
|
||||||
|
fmt.Errorf("run command on remote host: %w: %s", err, string(output))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHCLIRemote) Stream(ctx context.Context, cmd string, stdout, stderr io.Writer) error {
|
||||||
|
args := r.buildSSHArgs()
|
||||||
|
args = append(args, cmd)
|
||||||
|
|
||||||
|
execCmd := exec.CommandContext(ctx, "ssh", args...)
|
||||||
|
execCmd.Stdout = stdout
|
||||||
|
execCmd.Stderr = stderr
|
||||||
|
|
||||||
|
return execCmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// no-op as there is no persistent connection.
|
||||||
|
func (r *SSHCLIRemote) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package sshexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSHCLIRemote_buildSSHArgs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
user string
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
keyPath string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default port, no key",
|
||||||
|
user: "root",
|
||||||
|
host: "example.com",
|
||||||
|
port: 22,
|
||||||
|
keyPath: "",
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom port",
|
||||||
|
user: "ubuntu",
|
||||||
|
host: "192.168.1.10",
|
||||||
|
port: 2222,
|
||||||
|
keyPath: "",
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "ubuntu@192.168.1.10"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with key path",
|
||||||
|
user: "root",
|
||||||
|
host: "example.com",
|
||||||
|
port: 22,
|
||||||
|
keyPath: "/path/to/key",
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all options",
|
||||||
|
user: "admin",
|
||||||
|
host: "server.local",
|
||||||
|
port: 2222,
|
||||||
|
keyPath: "~/.ssh/id_rsa",
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "~/.ssh/id_rsa", "admin@server.local"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port zero (default)",
|
||||||
|
user: "root",
|
||||||
|
host: "example.com",
|
||||||
|
port: 0,
|
||||||
|
keyPath: "",
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "root@example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
remote := &SSHCLIRemote{
|
||||||
|
user: tt.user,
|
||||||
|
host: tt.host,
|
||||||
|
port: tt.port,
|
||||||
|
keyPath: tt.keyPath,
|
||||||
|
}
|
||||||
|
result := remote.buildSSHArgs()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -22,7 +22,7 @@ Uncloud stores its configuration in `~/.config/uncloud/config.yaml`. If you wish
|
|||||||
|
|
||||||
Begin by initialising the first node in your cluster with `uc machine init [USER@HOST:PORT]`. If you do not have a need for Caddy reverse proxy, you may disable this feature with `--no-caddy`. If you want to avoid using uncloud's managed DNS service, add the `--no-dns` flag.
|
Begin by initialising the first node in your cluster with `uc machine init [USER@HOST:PORT]`. If you do not have a need for Caddy reverse proxy, you may disable this feature with `--no-caddy`. If you want to avoid using uncloud's managed DNS service, add the `--no-dns` flag.
|
||||||
|
|
||||||
This command will idempotently install Docker, uncloudd, uncloud-corrosion. If Caddy is enabled, it will set up a reverse proxy. If Uncloud DNS is enabled, it will create a DNS A record for the machine's public IP address under `*.[CLUSTER ID].cluster.uncloud.run`.
|
This command will idempotently install Docker, uncloudd, uncloud-corrosion. If Caddy is enabled, it will set up a reverse proxy. If Uncloud DNS is enabled, it will create a DNS A record for the machine's public IP address under `*.[CLUSTER ID].uncld.dev`.
|
||||||
|
|
||||||
If you wish to uninstall Uncloud and its components, run `uncloud-uninstall`.
|
If you wish to uninstall Uncloud and its components, run `uncloud-uninstall`.
|
||||||
|
|
||||||
|
|||||||
+2
-41
@@ -2,13 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"google.golang.org/grpc/metadata"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
@@ -51,6 +48,8 @@ type ServiceClient interface {
|
|||||||
RunService(ctx context.Context, spec ServiceSpec) (RunServiceResponse, error)
|
RunService(ctx context.Context, spec ServiceSpec) (RunServiceResponse, error)
|
||||||
InspectService(ctx context.Context, id string) (Service, error)
|
InspectService(ctx context.Context, id string) (Service, error)
|
||||||
RemoveService(ctx context.Context, id string) error
|
RemoveService(ctx context.Context, id string) error
|
||||||
|
StopService(ctx context.Context, id string, opts container.StopOptions) error
|
||||||
|
StartService(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type VolumeClient interface {
|
type VolumeClient interface {
|
||||||
@@ -58,41 +57,3 @@ type VolumeClient interface {
|
|||||||
ListVolumes(ctx context.Context, filter *VolumeFilter) ([]MachineVolume, error)
|
ListVolumes(ctx context.Context, filter *VolumeFilter) ([]MachineVolume, error)
|
||||||
RemoveVolume(ctx context.Context, machineNameOrID, volumeName string, force bool) error
|
RemoveVolume(ctx context.Context, machineNameOrID, volumeName string, force bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
|
|
||||||
// If namesOrIDs is nil, all machines are included.
|
|
||||||
func ProxyMachinesContext(
|
|
||||||
ctx context.Context, cli MachineClient, namesOrIDs []string,
|
|
||||||
) (context.Context, MachineMembersList, error) {
|
|
||||||
// TODO: move the machine IP resolution to the proxy router to allow setting machine names and IDs in the metadata.
|
|
||||||
machines, err := cli.ListMachines(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("list machines: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var proxiedMachines MachineMembersList
|
|
||||||
var notFound []string
|
|
||||||
for _, nameOrID := range namesOrIDs {
|
|
||||||
if m := machines.FindByNameOrID(nameOrID); m != nil {
|
|
||||||
proxiedMachines = append(proxiedMachines, m)
|
|
||||||
} else {
|
|
||||||
notFound = append(notFound, nameOrID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(notFound) > 0 {
|
|
||||||
return nil, nil, fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(namesOrIDs) == 0 {
|
|
||||||
proxiedMachines = machines
|
|
||||||
}
|
|
||||||
|
|
||||||
md := metadata.New(nil)
|
|
||||||
for _, m := range proxiedMachines {
|
|
||||||
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
|
||||||
md.Append("machines", machineIP.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
LogStreamUnknown LogStreamType = iota
|
||||||
|
LogStreamStdout
|
||||||
|
LogStreamStderr
|
||||||
|
// LogStreamHeartbeat represents a heartbeat log entry with a timestamp indicating that
|
||||||
|
// there are no older logs than this timestamp.
|
||||||
|
LogStreamHeartbeat
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogStreamType int
|
||||||
|
|
||||||
|
// LogStreamTypeFromProto converts a protobuf ContainerLogEntry.StreamType to the internal LogStreamType.
|
||||||
|
func LogStreamTypeFromProto(s pb.ContainerLogEntry_StreamType) LogStreamType {
|
||||||
|
switch s {
|
||||||
|
case pb.ContainerLogEntry_STDOUT:
|
||||||
|
return LogStreamStdout
|
||||||
|
case pb.ContainerLogEntry_STDERR:
|
||||||
|
return LogStreamStderr
|
||||||
|
case pb.ContainerLogEntry_HEARTBEAT:
|
||||||
|
return LogStreamHeartbeat
|
||||||
|
default:
|
||||||
|
return LogStreamUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogStreamTypeToProto converts LogStreamType to protobuf ContainerLogEntry.StreamType.
|
||||||
|
func LogStreamTypeToProto(s LogStreamType) pb.ContainerLogEntry_StreamType {
|
||||||
|
switch s {
|
||||||
|
case LogStreamStdout:
|
||||||
|
return pb.ContainerLogEntry_STDOUT
|
||||||
|
case LogStreamStderr:
|
||||||
|
return pb.ContainerLogEntry_STDERR
|
||||||
|
case LogStreamHeartbeat:
|
||||||
|
return pb.ContainerLogEntry_HEARTBEAT
|
||||||
|
default:
|
||||||
|
return pb.ContainerLogEntry_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceLogsOptions specifies parameters for ServiceLogs.
|
||||||
|
type ServiceLogsOptions struct {
|
||||||
|
Follow bool
|
||||||
|
Tail int
|
||||||
|
Since string
|
||||||
|
Until string
|
||||||
|
// Machines filters logs to only include containers running on the specified machines (names or IDs).
|
||||||
|
// If empty, logs from all machines are included.
|
||||||
|
Machines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceLogEntry represents a single log entry from a service container.
|
||||||
|
type ServiceLogEntry struct {
|
||||||
|
// Metadata may not be set if an error occurred (Err is not nil).
|
||||||
|
Metadata ServiceLogEntryMetadata
|
||||||
|
ContainerLogEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceLogEntryMetadata contains metadata about the source of a log entry.
|
||||||
|
type ServiceLogEntryMetadata struct {
|
||||||
|
ServiceID string
|
||||||
|
ServiceName string
|
||||||
|
ContainerID string
|
||||||
|
MachineID string
|
||||||
|
MachineName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerLogEntry represents a single log entry from a container.
|
||||||
|
type ContainerLogEntry struct {
|
||||||
|
Stream LogStreamType
|
||||||
|
Timestamp time.Time
|
||||||
|
Message []byte
|
||||||
|
// Err indicates that an error occurred while streaming logs from a container.
|
||||||
|
// Other fields are not set if Err is not nil.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrLogStreamStalled indicates that a log stream stopped sending data and may be unresponsive.
|
||||||
|
var ErrLogStreamStalled = errors.New("log stream stopped responding")
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
@@ -366,6 +367,16 @@ type MachineServiceContainer struct {
|
|||||||
Container ServiceContainer
|
Container ServiceContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MachineIDs returns a list of unique machine IDs where the service containers are running.
|
||||||
|
func (s *Service) MachineIDs() []string {
|
||||||
|
ids := mapset.NewSet[string]()
|
||||||
|
for _, mc := range s.Containers {
|
||||||
|
ids.Add(mc.MachineID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids.ToSlice()
|
||||||
|
}
|
||||||
|
|
||||||
// Images returns a sorted list of unique images used by the service containers.
|
// Images returns a sorted list of unique images used by the service containers.
|
||||||
func (s *Service) Images() []string {
|
func (s *Service) Images() []string {
|
||||||
images := make(map[string]struct{})
|
images := make(map[string]struct{})
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ func (v *VolumeSpec) SetDefaults() VolumeSpec {
|
|||||||
if spec.VolumeOptions.Name == "" {
|
if spec.VolumeOptions.Name == "" {
|
||||||
spec.VolumeOptions.Name = spec.Name
|
spec.VolumeOptions.Name = spec.Name
|
||||||
}
|
}
|
||||||
|
if spec.VolumeOptions.Driver != nil && spec.VolumeOptions.Driver.Name == "" {
|
||||||
|
spec.VolumeOptions.Driver.Name = VolumeDriverLocal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// TODO: set explicit default values for Propagation and Recursive for bind mounts?
|
// TODO: set explicit default values for Propagation and Recursive for bind mounts?
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVolumeSpec_MatchesDockerVolume(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
spec VolumeSpec
|
||||||
|
vol volume.Volume
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "match with explicit local driver",
|
||||||
|
spec: VolumeSpec{
|
||||||
|
Type: VolumeTypeVolume,
|
||||||
|
VolumeOptions: &VolumeOptions{
|
||||||
|
Driver: &mount.Driver{
|
||||||
|
Name: "local",
|
||||||
|
Options: map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vol: volume.Volume{
|
||||||
|
Driver: "local",
|
||||||
|
Options: map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "match with empty driver name in spec (implicit local)",
|
||||||
|
spec: VolumeSpec{
|
||||||
|
Type: VolumeTypeVolume,
|
||||||
|
VolumeOptions: &VolumeOptions{
|
||||||
|
Driver: &mount.Driver{
|
||||||
|
Name: "", // Implicitly local
|
||||||
|
Options: map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vol: volume.Volume{
|
||||||
|
Driver: "local",
|
||||||
|
Options: map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-match with different driver options",
|
||||||
|
spec: VolumeSpec{
|
||||||
|
Type: VolumeTypeVolume,
|
||||||
|
VolumeOptions: &VolumeOptions{
|
||||||
|
Driver: &mount.Driver{
|
||||||
|
Name: "local",
|
||||||
|
Options: map[string]string{
|
||||||
|
"foo": "baz",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vol: volume.Volume{
|
||||||
|
Driver: "local",
|
||||||
|
Options: map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-match with different driver name",
|
||||||
|
spec: VolumeSpec{
|
||||||
|
Type: VolumeTypeVolume,
|
||||||
|
VolumeOptions: &VolumeOptions{
|
||||||
|
Driver: &mount.Driver{
|
||||||
|
Name: "custom",
|
||||||
|
Options: map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vol: volume.Volume{
|
||||||
|
Driver: "local",
|
||||||
|
Options: map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "match external volume without driver by name only",
|
||||||
|
spec: VolumeSpec{
|
||||||
|
Name: "external",
|
||||||
|
Type: VolumeTypeVolume,
|
||||||
|
},
|
||||||
|
vol: volume.Volume{
|
||||||
|
Name: "external",
|
||||||
|
Driver: "local",
|
||||||
|
Options: map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-match external volume by name",
|
||||||
|
spec: VolumeSpec{
|
||||||
|
Name: "unknown",
|
||||||
|
Type: VolumeTypeVolume,
|
||||||
|
},
|
||||||
|
vol: volume.Volume{
|
||||||
|
Name: "external",
|
||||||
|
Driver: "local",
|
||||||
|
Options: map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
matches := tt.spec.MatchesDockerVolume(tt.vol)
|
||||||
|
assert.Equal(t, tt.expected, matches)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,13 @@ func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placem
|
|||||||
Protocol: api.ProtocolTCP,
|
Protocol: api.ProtocolTCP,
|
||||||
Mode: api.PortModeHost,
|
Mode: api.PortModeHost,
|
||||||
},
|
},
|
||||||
|
// Needed for HTTP/3 (QUIC)
|
||||||
|
{
|
||||||
|
PublishedPort: 443,
|
||||||
|
ContainerPort: 443,
|
||||||
|
Protocol: api.ProtocolUDP,
|
||||||
|
Mode: api.PortModeHost,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Volumes: []api.VolumeSpec{
|
Volumes: []api.VolumeSpec{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/cli/cli/streams"
|
"github.com/docker/cli/cli/streams"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
@@ -76,3 +77,41 @@ func proxyToMachine(ctx context.Context, machine *pb.MachineInfo) context.Contex
|
|||||||
md := metadata.Pairs("machines", machineIP.String())
|
md := metadata.Pairs("machines", machineIP.String())
|
||||||
return metadata.NewOutgoingContext(ctx, md)
|
return metadata.NewOutgoingContext(ctx, md)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
|
||||||
|
// If namesOrIDs is nil, all machines are included.
|
||||||
|
func (cli *Client) ProxyMachinesContext(
|
||||||
|
ctx context.Context, namesOrIDs []string,
|
||||||
|
) (context.Context, api.MachineMembersList, error) {
|
||||||
|
// TODO: move the machine IP resolution to the proxy router to allow setting machine names and IDs in the metadata.
|
||||||
|
machines, err := cli.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxiedMachines api.MachineMembersList
|
||||||
|
var notFound []string
|
||||||
|
for _, nameOrID := range namesOrIDs {
|
||||||
|
if m := machines.FindByNameOrID(nameOrID); m != nil {
|
||||||
|
proxiedMachines = append(proxiedMachines, m)
|
||||||
|
} else {
|
||||||
|
notFound = append(notFound, nameOrID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(notFound) > 0 {
|
||||||
|
return nil, nil, fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(namesOrIDs) == 0 {
|
||||||
|
proxiedMachines = machines
|
||||||
|
}
|
||||||
|
|
||||||
|
md := metadata.New(nil)
|
||||||
|
for _, m := range proxiedMachines {
|
||||||
|
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||||
|
md.Append("machines", machineIP.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package compose
|
package compose
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -178,7 +181,15 @@ services:
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
project, err := loadProjectFromContent(t, tt.composeYAML)
|
// Get current working directory for relative path resolution to testdata directory.
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
project, err := LoadProjectFromContent(
|
||||||
|
context.Background(),
|
||||||
|
tt.composeYAML,
|
||||||
|
composecli.WithWorkingDirectory(wd),
|
||||||
|
)
|
||||||
|
|
||||||
if tt.wantErr != "" {
|
if tt.wantErr != "" {
|
||||||
require.ErrorContains(t, err, tt.wantErr)
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
|||||||
@@ -49,10 +49,6 @@ func NewDeploymentWithStrategy(ctx context.Context, cli Client, project *types.P
|
|||||||
ClusterDomain: domain,
|
ClusterDomain: domain,
|
||||||
}
|
}
|
||||||
|
|
||||||
if strategy == nil {
|
|
||||||
strategy = &deploy.RollingStrategy{State: state}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Deployment{
|
return &Deployment{
|
||||||
Client: cli,
|
Client: cli,
|
||||||
Project: project,
|
Project: project,
|
||||||
@@ -88,6 +84,7 @@ func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check external volumes and plan the creation of missing volumes before deploying services.
|
// Check external volumes and plan the creation of missing volumes before deploying services.
|
||||||
|
// Updates the cluster state (d.state) with the scheduled volumes.
|
||||||
volumeOps, err := d.planVolumes(serviceSpecs)
|
volumeOps, err := d.planVolumes(serviceSpecs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return plan, err
|
return plan, err
|
||||||
@@ -98,8 +95,8 @@ func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error)
|
|||||||
|
|
||||||
for _, spec := range serviceSpecs {
|
for _, spec := range serviceSpecs {
|
||||||
// TODO: properly handle depends_on conditions in the service deployment plan as the first operation.
|
// TODO: properly handle depends_on conditions in the service deployment plan as the first operation.
|
||||||
// Pass the update cluster state with scheduled volumes to the deployment.
|
// Pass the updated cluster state with the scheduled volumes to the deployment.
|
||||||
deployment := deploy.NewDeployment(d.Client, spec, d.Strategy)
|
deployment := deploy.NewDeploymentWithClusterState(d.Client, spec, d.Strategy, d.state)
|
||||||
servicePlan, err := deployment.Plan(ctx)
|
servicePlan, err := deployment.Plan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return plan, fmt.Errorf("create deployment plan for service '%s': %w", spec.Name, err)
|
return plan, fmt.Errorf("create deployment plan for service '%s': %w", spec.Name, err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package compose
|
package compose
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -301,7 +302,7 @@ services:
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
project, err := loadProjectFromContent(t, tt.content)
|
project, err := LoadProjectFromContent(context.Background(), tt.content)
|
||||||
if tt.wantErr != "" {
|
if tt.wantErr != "" {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), tt.wantErr)
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
@@ -375,7 +376,7 @@ services:
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
project, err := loadProjectFromContent(t, tt.content)
|
project, err := LoadProjectFromContent(context.Background(), tt.content)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
service, err := project.GetService("web")
|
service, err := project.GetService("web")
|
||||||
@@ -402,7 +403,7 @@ services:
|
|||||||
- "9090:9090/tcp@host"
|
- "9090:9090/tcp@host"
|
||||||
`
|
`
|
||||||
|
|
||||||
project, err := loadProjectFromContent(t, content)
|
project, err := LoadProjectFromContent(context.Background(), content)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
service, err := project.GetService("web")
|
service, err := project.GetService("web")
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package compose
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
"github.com/compose-spec/compose-go/v2/types"
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LoadProject loads a Compose project from the default locations or the given paths.
|
||||||
func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) {
|
func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) {
|
||||||
defaultOpts := []composecli.ProjectOptionsFn{
|
defaultOpts := []composecli.ProjectOptionsFn{
|
||||||
// First apply os.Environment, always wins.
|
// First apply os.Environment, always wins.
|
||||||
@@ -61,6 +64,26 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
|||||||
return project, nil
|
return project, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadProjectFromContent loads a Compose project from the given YAML content.
|
||||||
|
func LoadProjectFromContent(
|
||||||
|
ctx context.Context, content string, opts ...composecli.ProjectOptionsFn,
|
||||||
|
) (*types.Project, error) {
|
||||||
|
// Create a temporary directory for the compose file.
|
||||||
|
tmpDir, err := os.MkdirTemp("", "uncloud-compose-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create temporary directory: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Write the YAML content to compose.yaml in the temporary directory.
|
||||||
|
composePath := filepath.Join(tmpDir, "compose.yaml")
|
||||||
|
if err := os.WriteFile(composePath, []byte(content), 0o644); err != nil {
|
||||||
|
return nil, fmt.Errorf("write compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoadProject(ctx, []string{composePath}, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
// removeProjectPrefixFromNames removes the project name prefix from volume names.
|
// removeProjectPrefixFromNames removes the project name prefix from volume names.
|
||||||
func removeProjectPrefixFromNames(project *types.Project) {
|
func removeProjectPrefixFromNames(project *types.Project) {
|
||||||
prefix := project.Name + "_"
|
prefix := project.Name + "_"
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ func dockerVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig, volume
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !volume.External {
|
if !volume.External {
|
||||||
if volume.Driver != "" {
|
if volume.Driver != "" || len(volume.DriverOpts) > 0 {
|
||||||
spec.VolumeOptions.Driver = &mount.Driver{
|
spec.VolumeOptions.Driver = &mount.Driver{
|
||||||
Name: volume.Driver,
|
Name: volume.Driver,
|
||||||
Options: volume.DriverOpts,
|
Options: volume.DriverOpts,
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package compose
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/compose-spec/compose-go/v2/loader"
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
"github.com/compose-spec/compose-go/v2/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
@@ -20,57 +20,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// loadProjectFromContent loads a compose project from YAML content.
|
|
||||||
// Keep the implementation in sync with LoadProject.
|
|
||||||
func loadProjectFromContent(t *testing.T, content string) (*types.Project, error) {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
configDetails := types.ConfigDetails{
|
|
||||||
ConfigFiles: []types.ConfigFile{
|
|
||||||
{
|
|
||||||
Filename: "docker-compose.yml",
|
|
||||||
Content: []byte(content),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
project, err := loader.LoadWithContext(ctx, configDetails, func(o *loader.Options) {
|
|
||||||
o.SetProjectName("test", true)
|
|
||||||
// Register our custom extensions
|
|
||||||
if o.KnownExtensions == nil {
|
|
||||||
o.KnownExtensions = map[string]any{}
|
|
||||||
}
|
|
||||||
o.KnownExtensions[CaddyExtensionKey] = Caddy{}
|
|
||||||
o.KnownExtensions[PortsExtensionKey] = PortsSource{}
|
|
||||||
o.KnownExtensions[MachinesExtensionKey] = MachinesSource{}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
removeProjectPrefixFromNames(project)
|
|
||||||
// Apply extension transformations since we're not using LoadProject.
|
|
||||||
if project, err = transformServicesCaddyExtension(project); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if project, err = transformServicesPortsExtension(project); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate extension combinations after all transformations.
|
|
||||||
if err = validateServicesExtensions(project); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process image templates in services to expand Go template expressions using git repo state.
|
|
||||||
if project, err = ProcessImageTemplates(project); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return project, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceSpecFromCompose(t *testing.T) {
|
func TestServiceSpecFromCompose(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -431,7 +380,15 @@ services:
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
project, err := loadProjectFromContent(t, tt.composeYAML)
|
// Get current working directory for relative path resolution to testdata directory.
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
project, err := LoadProjectFromContent(
|
||||||
|
context.Background(),
|
||||||
|
tt.composeYAML,
|
||||||
|
composecli.WithWorkingDirectory(wd),
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
spec, err := ServiceSpecFromCompose(project, "web")
|
spec, err := ServiceSpecFromCompose(project, "web")
|
||||||
@@ -617,7 +574,7 @@ services:
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
project, err := loadProjectFromContent(t, tt.composeYAML)
|
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
spec, err := ServiceSpecFromCompose(project, "ai")
|
spec, err := ServiceSpecFromCompose(project, "ai")
|
||||||
@@ -629,6 +586,114 @@ services:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceSpecFromCompose_VolumeDriverOpts(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
composeYAML string
|
||||||
|
expectedVolume api.VolumeSpec
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "volume with driver_opts only (no driver specified)",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: nginx
|
||||||
|
volumes:
|
||||||
|
- nfsmount:/data/nfs
|
||||||
|
volumes:
|
||||||
|
nfsmount:
|
||||||
|
driver_opts:
|
||||||
|
type: "nfs"
|
||||||
|
o: "addr=192.168.1.100,nolock,soft"
|
||||||
|
device: ":/mnt/share"
|
||||||
|
`,
|
||||||
|
expectedVolume: api.VolumeSpec{
|
||||||
|
Name: "nfsmount",
|
||||||
|
Type: api.VolumeTypeVolume,
|
||||||
|
VolumeOptions: &api.VolumeOptions{
|
||||||
|
Name: "nfsmount",
|
||||||
|
Driver: &mount.Driver{
|
||||||
|
Options: map[string]string{
|
||||||
|
"type": "nfs",
|
||||||
|
"o": "addr=192.168.1.100,nolock,soft",
|
||||||
|
"device": ":/mnt/share",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "volume with driver and driver_opts",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: nginx
|
||||||
|
volumes:
|
||||||
|
- nfsmount:/data/nfs
|
||||||
|
volumes:
|
||||||
|
nfsmount:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: "nfs"
|
||||||
|
o: "addr=192.168.1.100,nolock,soft"
|
||||||
|
device: ":/mnt/share"
|
||||||
|
`,
|
||||||
|
expectedVolume: api.VolumeSpec{
|
||||||
|
Name: "nfsmount",
|
||||||
|
Type: api.VolumeTypeVolume,
|
||||||
|
VolumeOptions: &api.VolumeOptions{
|
||||||
|
Name: "nfsmount",
|
||||||
|
Driver: &mount.Driver{
|
||||||
|
Name: "local",
|
||||||
|
Options: map[string]string{
|
||||||
|
"type": "nfs",
|
||||||
|
"o": "addr=192.168.1.100,nolock,soft",
|
||||||
|
"device": ":/mnt/share",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "external volume ignores driver_opts",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: nginx
|
||||||
|
volumes:
|
||||||
|
- extvolume:/data
|
||||||
|
volumes:
|
||||||
|
extvolume:
|
||||||
|
external: true
|
||||||
|
`,
|
||||||
|
expectedVolume: api.VolumeSpec{
|
||||||
|
Name: "extvolume",
|
||||||
|
Type: api.VolumeTypeVolume,
|
||||||
|
VolumeOptions: &api.VolumeOptions{
|
||||||
|
Name: "extvolume",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
spec, err := ServiceSpecFromCompose(project, "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, spec.Volumes, 1, "expected exactly one volume")
|
||||||
|
actualVolume := spec.Volumes[0]
|
||||||
|
|
||||||
|
cmpOpts := cmp.Options{cmpopts.EquateEmpty()}
|
||||||
|
assert.True(t, cmp.Equal(actualVolume, tt.expectedVolume, cmpOpts...),
|
||||||
|
"Volume mismatch:\n%s", cmp.Diff(actualVolume, tt.expectedVolume, cmpOpts...))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
|
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -765,7 +830,7 @@ services:
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
project, err := loadProjectFromContent(t, tt.composeYAML)
|
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnixConnector establishes a connection to the machine API through a unix domain socket.
|
||||||
|
type UnixConnector struct {
|
||||||
|
socketPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUnixConnector(socketPath string) *UnixConnector {
|
||||||
|
return &UnixConnector{socketPath: socketPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *UnixConnector) Connect(_ context.Context) (*grpc.ClientConn, error) {
|
||||||
|
// gRPC uses "unix:path" syntax for unix sockets.
|
||||||
|
target := "unix:" + c.socketPath
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient(
|
||||||
|
target,
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create machine API client: %w", err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *UnixConnector) Dialer() (proxy.ContextDialer, error) {
|
||||||
|
return nil, fmt.Errorf("proxy connections are not supported over a unix connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *UnixConnector) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
@@ -25,6 +26,8 @@ type Deployment struct {
|
|||||||
Strategy Strategy
|
Strategy Strategy
|
||||||
cli Client
|
cli Client
|
||||||
plan *Plan
|
plan *Plan
|
||||||
|
// state is an optional current and planned cluster state used for scheduling decisions.
|
||||||
|
state *scheduler.ClusterState
|
||||||
}
|
}
|
||||||
|
|
||||||
type Plan struct {
|
type Plan struct {
|
||||||
@@ -47,6 +50,16 @@ func NewDeployment(cli Client, spec api.ServiceSpec, strategy Strategy) *Deploym
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewDeploymentWithClusterState creates a new deployment like NewDeployment but also with a provided current cluster
|
||||||
|
// state used for scheduling decisions.
|
||||||
|
func NewDeploymentWithClusterState(
|
||||||
|
cli Client, spec api.ServiceSpec, strategy Strategy, state *scheduler.ClusterState,
|
||||||
|
) *Deployment {
|
||||||
|
d := NewDeployment(cli, spec, strategy)
|
||||||
|
d.state = state
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
// Plan returns a plan of operations to reconcile the service to the desired state.
|
// Plan returns a plan of operations to reconcile the service to the desired state.
|
||||||
// If a plan has already been created, the same plan will be returned.
|
// If a plan has already been created, the same plan will be returned.
|
||||||
func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
|
func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
|
||||||
@@ -73,7 +86,14 @@ func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
|
|||||||
return Plan{}, fmt.Errorf("resolve service spec: %w", err)
|
return Plan{}, fmt.Errorf("resolve service spec: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := d.Strategy.Plan(ctx, d.cli, d.Service, resolvedSpec)
|
if d.state == nil {
|
||||||
|
d.state, err = scheduler.InspectClusterState(ctx, d.cli)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("inspect cluster state: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := d.Strategy.Plan(d.state, d.Service, resolvedSpec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Plan{}, fmt.Errorf("create plan using %s strategy: %w", d.Strategy.Type(), err)
|
return Plan{}, fmt.Errorf("create plan using %s strategy: %w", d.Strategy.Type(), err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
// - If a volume already exists on a machine, it must be used instead of creating a new one.
|
// - If a volume already exists on a machine, it must be used instead of creating a new one.
|
||||||
// - A missing volume must only be created on one machine.
|
// - A missing volume must only be created on one machine.
|
||||||
type VolumeScheduler struct {
|
type VolumeScheduler struct {
|
||||||
// state is the current state of machines and their resources in the cluster.
|
// state is the current and planned state of machines and their resources in the cluster.
|
||||||
state *ClusterState
|
state *ClusterState
|
||||||
// serviceSpecs is a list of service specifications included in the deployment.
|
// serviceSpecs is a list of service specifications included in the deployment.
|
||||||
serviceSpecs []api.ServiceSpec
|
serviceSpecs []api.ServiceSpec
|
||||||
@@ -83,7 +83,10 @@ func NewVolumeScheduler(state *ClusterState, specs []api.ServiceSpec) (*VolumeSc
|
|||||||
if spec, ok := volumeSpecs[vol.Name]; ok {
|
if spec, ok := volumeSpecs[vol.Name]; ok {
|
||||||
if !spec.MatchesDockerVolume(vol) {
|
if !spec.MatchesDockerVolume(vol) {
|
||||||
return nil, fmt.Errorf("volume '%s' specification does not match the existing volume "+
|
return nil, fmt.Errorf("volume '%s' specification does not match the existing volume "+
|
||||||
"on machine '%s'", vol.Name, machine.Info.Name)
|
"on machine '%s'. Use a different volume name or adjust the volume options to match "+
|
||||||
|
"the existing volume. You can also remove the existing volume from the machine(s) with "+
|
||||||
|
"'uc volume rm' (WARNING: the data will be lost) and run the deployment again to create "+
|
||||||
|
"a new volume with the correct specification", vol.Name, machine.Info.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, setInitialised := existingVolumeMachines[vol.Name]; !setInitialised {
|
if _, setInitialised := existingVolumeMachines[vol.Name]; !setInitialised {
|
||||||
@@ -106,6 +109,7 @@ func NewVolumeScheduler(state *ClusterState, specs []api.ServiceSpec) (*VolumeSc
|
|||||||
// Schedule determines what missing volumes should be created and where for services in the multi-service deployment.
|
// Schedule determines what missing volumes should be created and where for services in the multi-service deployment.
|
||||||
// It returns a map of machine IDs to a list of api.VolumeSpec that should be created on that machine,
|
// It returns a map of machine IDs to a list of api.VolumeSpec that should be created on that machine,
|
||||||
// or an error if services can't be scheduled due to scheduling constraints.
|
// or an error if services can't be scheduled due to scheduling constraints.
|
||||||
|
// It also updates the state of the machines in the cluster state to reflect the scheduled volumes.
|
||||||
func (s *VolumeScheduler) Schedule() (map[string][]api.VolumeSpec, error) {
|
func (s *VolumeScheduler) Schedule() (map[string][]api.VolumeSpec, error) {
|
||||||
if len(s.serviceSpecs) == 0 {
|
if len(s.serviceSpecs) == 0 {
|
||||||
// No services with volume mounts, nothing to schedule.
|
// No services with volume mounts, nothing to schedule.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package deploy
|
package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -18,31 +17,31 @@ type Strategy interface {
|
|||||||
// Type returns the type of the deployment strategy, e.g. "rolling", "blue-green".
|
// Type returns the type of the deployment strategy, e.g. "rolling", "blue-green".
|
||||||
Type() string
|
Type() string
|
||||||
// Plan returns the operation to reconcile the service to the desired state.
|
// Plan returns the operation to reconcile the service to the desired state.
|
||||||
// If the service does not exist (new deployment), svc will be nil.
|
// If the service does not exist (new deployment), svc will be nil. state provides the current and planned state
|
||||||
Plan(ctx context.Context, cli scheduler.Client, svc *api.Service, spec api.ServiceSpec) (Plan, error)
|
// of the cluster for scheduling decisions.
|
||||||
|
Plan(state *scheduler.ClusterState, svc *api.Service, spec api.ServiceSpec) (Plan, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time
|
// RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time
|
||||||
// to minimize service disruption.
|
// to minimize service disruption.
|
||||||
type RollingStrategy struct {
|
type RollingStrategy struct {
|
||||||
State *scheduler.ClusterState
|
// ForceRecreate indicates whether all containers should be recreated during the deployment,
|
||||||
|
// regardless of whether their specifications have changed.
|
||||||
ForceRecreate bool
|
ForceRecreate bool
|
||||||
|
|
||||||
|
// state is the current and planned state of the cluster used for scheduling decisions.
|
||||||
|
state *scheduler.ClusterState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RollingStrategy) Type() string {
|
func (s *RollingStrategy) Type() string {
|
||||||
return "rolling"
|
return "rolling"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RollingStrategy) Plan(
|
func (s *RollingStrategy) Plan(state *scheduler.ClusterState, svc *api.Service, spec api.ServiceSpec) (Plan, error) {
|
||||||
ctx context.Context, cli scheduler.Client, svc *api.Service, spec api.ServiceSpec,
|
if state == nil {
|
||||||
) (Plan, error) {
|
return Plan{}, fmt.Errorf("cluster state must be provided")
|
||||||
if s.State == nil {
|
|
||||||
state, err := scheduler.InspectClusterState(ctx, cli)
|
|
||||||
if err != nil {
|
|
||||||
return Plan{}, fmt.Errorf("inspect cluster state: %w", err)
|
|
||||||
}
|
|
||||||
s.State = state
|
|
||||||
}
|
}
|
||||||
|
s.state = state
|
||||||
|
|
||||||
// We can assume that the spec is valid at this point because it has been validated by the deployment.
|
// We can assume that the spec is valid at this point because it has been validated by the deployment.
|
||||||
switch spec.Mode {
|
switch spec.Mode {
|
||||||
@@ -65,7 +64,7 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
|||||||
return plan, err
|
return plan, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sched := scheduler.NewServiceScheduler(s.State, spec)
|
sched := scheduler.NewServiceScheduler(s.state, spec)
|
||||||
// TODO: return a detailed report on required constraints and which ones are satisfied?
|
// TODO: return a detailed report on required constraints and which ones are satisfied?
|
||||||
availableMachines, err := sched.EligibleMachines()
|
availableMachines, err := sched.EligibleMachines()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -221,7 +220,7 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sched := scheduler.NewServiceScheduler(s.State, spec)
|
sched := scheduler.NewServiceScheduler(s.state, spec)
|
||||||
availableMachines, err := sched.EligibleMachines()
|
availableMachines, err := sched.EligibleMachines()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return plan, err
|
return plan, err
|
||||||
|
|||||||
+1
-13
@@ -48,7 +48,7 @@ func (cli *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.Mac
|
|||||||
// it lists images on all machines.
|
// it lists images on all machines.
|
||||||
func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]api.MachineImages, error) {
|
func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]api.MachineImages, error) {
|
||||||
// Broadcast the image list request to the specified machines or all machines if none specified.
|
// Broadcast the image list request to the specified machines or all machines if none specified.
|
||||||
listCtx, machines, err := api.ProxyMachinesContext(ctx, cli, filter.Machines)
|
listCtx, machines, err := cli.ProxyMachinesContext(ctx, filter.Machines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create request context to broadcast to machines: %w", err)
|
return nil, fmt.Errorf("create request context to broadcast to machines: %w", err)
|
||||||
}
|
}
|
||||||
@@ -149,18 +149,6 @@ func (cli *Client) PushImage(ctx context.Context, image string, opts PushImageOp
|
|||||||
return fmt.Errorf("list machines: %w", err)
|
return fmt.Errorf("list machines: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if all specified machines were found.
|
|
||||||
if len(machineMembers) != len(opts.Machines) {
|
|
||||||
var notFound []string
|
|
||||||
for _, nameOrID := range opts.Machines {
|
|
||||||
if machineMembers.FindByNameOrID(nameOrID) == nil {
|
|
||||||
notFound = append(notFound, nameOrID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, mm := range machineMembers {
|
for _, mm := range machineMembers {
|
||||||
machines = append(machines, mm.Machine)
|
machines = append(machines, mm.Machine)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/heap"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logMergerMaxInFlightPerStream limits how many entries each input stream can have in the processing queue before being
|
||||||
|
// throttled. This ensures fair interleaving between streams and prevents one fast stream from causing unbounded
|
||||||
|
// buffering while waiting for slower streams.
|
||||||
|
const (
|
||||||
|
logMergerMaxInFlightPerStream = 100
|
||||||
|
// logMergerHeartbeatDebounceInterval defines the minimum interval between deduplicated heartbeat entries emitted
|
||||||
|
// by the LogMerger. Keep it in sync with logsHeartbeatInterval internal/machine/docker/server.go.
|
||||||
|
logMergerHeartbeatDebounceInterval = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogMergerOptions configures the behavior of LogMerger.
|
||||||
|
type LogMergerOptions struct {
|
||||||
|
// StallTimeout specifies how long a stream can go without receiving any data before it's considered
|
||||||
|
// stalled and excluded from watermark calculation. A zero timeout disables stall detection.
|
||||||
|
StallTimeout time.Duration
|
||||||
|
// StallCheckInterval specifies how often to check for stalled streams.
|
||||||
|
StallCheckInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLogMergerOptions provides sensible default options that enable stall detection for LogMerger.
|
||||||
|
var DefaultLogMergerOptions = LogMergerOptions{
|
||||||
|
StallTimeout: 10 * time.Second,
|
||||||
|
StallCheckInterval: 1 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogMerger merges multiple log streams into a single chronologically ordered stream based on timestamps.
|
||||||
|
// It uses a low watermark algorithm to ensure proper ordering across streams.
|
||||||
|
// Heartbeat entries from streams advance the watermark to enable timely emission of buffered logs.
|
||||||
|
type LogMerger struct {
|
||||||
|
streams []*mergerStream
|
||||||
|
queue logsHeap
|
||||||
|
// watermark is min(latest_timestamp for each stream).
|
||||||
|
watermark time.Time
|
||||||
|
output chan api.ServiceLogEntry
|
||||||
|
// lastEmitted is the timestamp of the last emitted log entry or heartbeat.
|
||||||
|
lastEmitted time.Time
|
||||||
|
stallTimeout time.Duration
|
||||||
|
stallCheckInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogMerger creates a new LogMerger for the given input streams with the specified options.
|
||||||
|
func NewLogMerger(streams []<-chan api.ServiceLogEntry, opts LogMergerOptions) *LogMerger {
|
||||||
|
mergerStreams := make([]*mergerStream, len(streams))
|
||||||
|
now := time.Now()
|
||||||
|
for i, ch := range streams {
|
||||||
|
mergerStreams[i] = &mergerStream{
|
||||||
|
stream: ch,
|
||||||
|
semaphore: make(chan struct{}, logMergerMaxInFlightPerStream),
|
||||||
|
lastActivity: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LogMerger{
|
||||||
|
streams: mergerStreams,
|
||||||
|
output: make(chan api.ServiceLogEntry),
|
||||||
|
stallTimeout: opts.StallTimeout,
|
||||||
|
stallCheckInterval: opts.StallCheckInterval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream starts the merge process and returns a channel that emits log entries in chronological order.
|
||||||
|
// The returned channel is closed when all input streams are closed.
|
||||||
|
func (m *LogMerger) Stream() <-chan api.ServiceLogEntry {
|
||||||
|
if len(m.streams) == 0 {
|
||||||
|
close(m.output)
|
||||||
|
return m.output
|
||||||
|
}
|
||||||
|
|
||||||
|
go m.run()
|
||||||
|
|
||||||
|
return m.output
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergerStream combines a stream channel with its state and flow control.
|
||||||
|
type mergerStream struct {
|
||||||
|
stream <-chan api.ServiceLogEntry
|
||||||
|
semaphore chan struct{}
|
||||||
|
// Latest timestamp seen from this stream (log or heartbeat).
|
||||||
|
lastSeen time.Time
|
||||||
|
// Wall clock time when we last received any data from this stream.
|
||||||
|
lastActivity time.Time
|
||||||
|
// Metadata associated with this stream. It's populated from the first log entry received.
|
||||||
|
metadata *api.ServiceLogEntryMetadata
|
||||||
|
// Whether the stream channel has closed.
|
||||||
|
closed bool
|
||||||
|
// Whether the stream is considered stalled (no data received within timeout).
|
||||||
|
stalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamEvent represents an event from a stream (entry received or stream closed).
|
||||||
|
type streamEvent struct {
|
||||||
|
stream *mergerStream
|
||||||
|
entry api.ServiceLogEntry
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// queuedEntry wraps a log entry with its source semaphore for release tracking.
|
||||||
|
type queuedEntry struct {
|
||||||
|
entry api.ServiceLogEntry
|
||||||
|
semaphore chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run is the main processing loop that merges all streams.
|
||||||
|
func (m *LogMerger) run() {
|
||||||
|
defer close(m.output)
|
||||||
|
|
||||||
|
// Fan-in channel for stream events.
|
||||||
|
events := make(chan streamEvent)
|
||||||
|
|
||||||
|
// Start a reader goroutine for each stream to send entries to the events channel with flow control.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, stream := range m.streams {
|
||||||
|
wg.Go(func() {
|
||||||
|
for entry := range stream.stream {
|
||||||
|
// Acquire semaphore slot before sending the entry to limit in-flight unprocessed entries per stream.
|
||||||
|
stream.semaphore <- struct{}{}
|
||||||
|
events <- streamEvent{stream: stream, entry: entry}
|
||||||
|
}
|
||||||
|
|
||||||
|
events <- streamEvent{stream: stream, closed: true}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close events channel when all readers finish.
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(events)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Set up stall detection timer if enabled.
|
||||||
|
var stallCh <-chan time.Time
|
||||||
|
if m.stallTimeout > 0 && m.stallCheckInterval > 0 {
|
||||||
|
stallTicker := time.NewTicker(m.stallCheckInterval)
|
||||||
|
stallCh = stallTicker.C
|
||||||
|
defer stallTicker.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process events and emit entries.
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case e, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
// All streams closed: flush remaining entries in order.
|
||||||
|
for m.queue.Len() > 0 {
|
||||||
|
qe := heap.Pop(&m.queue).(queuedEntry)
|
||||||
|
m.output <- qe.entry
|
||||||
|
<-qe.semaphore
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.stream.lastActivity = time.Now()
|
||||||
|
if e.stream.stalled {
|
||||||
|
e.stream.stalled = false
|
||||||
|
}
|
||||||
|
if e.stream.metadata == nil {
|
||||||
|
e.stream.metadata = &e.entry.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.closed {
|
||||||
|
e.stream.closed = true
|
||||||
|
|
||||||
|
m.updateWatermark()
|
||||||
|
m.emitReadyEntries()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward errors immediately and release semaphore.
|
||||||
|
if e.entry.Err != nil {
|
||||||
|
m.output <- e.entry
|
||||||
|
<-e.stream.semaphore
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.entry.Timestamp.After(e.stream.lastSeen) {
|
||||||
|
e.stream.lastSeen = e.entry.Timestamp
|
||||||
|
}
|
||||||
|
if e.entry.Stream == api.LogStreamStdout || e.entry.Stream == api.LogStreamStderr {
|
||||||
|
heap.Push(&m.queue, queuedEntry{entry: e.entry, semaphore: e.stream.semaphore})
|
||||||
|
}
|
||||||
|
|
||||||
|
m.updateWatermark()
|
||||||
|
m.emitReadyEntries()
|
||||||
|
|
||||||
|
// When merging streams, each input emits its own heartbeats. We want to debounce them and emit our own
|
||||||
|
// heartbeats at the same rate as a single input stream. Note that we need to adjust the heartbeat timestamp
|
||||||
|
// to the current watermark to not violate ordering guarantees.
|
||||||
|
if e.entry.Stream == api.LogStreamHeartbeat {
|
||||||
|
if m.watermark.Sub(m.lastEmitted) >= logMergerHeartbeatDebounceInterval {
|
||||||
|
heartbeat := e.entry
|
||||||
|
heartbeat.Timestamp = m.watermark
|
||||||
|
m.output <- heartbeat
|
||||||
|
m.lastEmitted = m.watermark
|
||||||
|
}
|
||||||
|
// Heartbeat processed, release semaphore.
|
||||||
|
<-e.stream.semaphore
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-stallCh:
|
||||||
|
stalled := m.checkStalledStreams()
|
||||||
|
if len(stalled) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range stalled {
|
||||||
|
errEntry := api.ServiceLogEntry{
|
||||||
|
ContainerLogEntry: api.ContainerLogEntry{
|
||||||
|
Err: api.ErrLogStreamStalled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if s.metadata != nil {
|
||||||
|
errEntry.Metadata = *s.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
m.output <- errEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
m.updateWatermark()
|
||||||
|
m.emitReadyEntries()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStalledStreams marks streams as stalled if they haven't received any data within the timeout.
|
||||||
|
// Returns true if any stream's stalled state changed.
|
||||||
|
func (m *LogMerger) checkStalledStreams() []*mergerStream {
|
||||||
|
var stalled []*mergerStream
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, s := range m.streams {
|
||||||
|
if s.closed || s.stalled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.Sub(s.lastActivity) > m.stallTimeout {
|
||||||
|
s.stalled = true
|
||||||
|
stalled = append(stalled, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stalled
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateWatermark recalculates the low watermark based on the lastSeen timestamps of all active streams.
|
||||||
|
func (m *LogMerger) updateWatermark() {
|
||||||
|
first := true
|
||||||
|
|
||||||
|
for _, s := range m.streams {
|
||||||
|
if s.closed || s.stalled {
|
||||||
|
// Closed and stalled streams don't affect watermark.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if first || s.lastSeen.Before(m.watermark) {
|
||||||
|
m.watermark = s.lastSeen
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitReadyEntries pops and emits all buffered entries from the queue with timestamp before the watermark.
|
||||||
|
func (m *LogMerger) emitReadyEntries() {
|
||||||
|
if m.watermark.IsZero() {
|
||||||
|
// No entries received yet.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for m.queue.Len() > 0 && m.queue[0].entry.Timestamp.Compare(m.watermark) <= 0 {
|
||||||
|
qe := heap.Pop(&m.queue).(queuedEntry)
|
||||||
|
m.output <- qe.entry
|
||||||
|
m.lastEmitted = qe.entry.Timestamp
|
||||||
|
<-qe.semaphore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logsHeap is a min-heap (heap.Interface) of queued entries ordered by timestamp.
|
||||||
|
type logsHeap []queuedEntry
|
||||||
|
|
||||||
|
func (h *logsHeap) Len() int {
|
||||||
|
return len(*h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *logsHeap) Less(i, j int) bool {
|
||||||
|
return (*h)[i].entry.Timestamp.Before((*h)[j].entry.Timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *logsHeap) Swap(i, j int) {
|
||||||
|
(*h)[i], (*h)[j] = (*h)[j], (*h)[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *logsHeap) Push(x any) {
|
||||||
|
*h = append(*h, x.(queuedEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *logsHeap) Pop() any {
|
||||||
|
old := *h
|
||||||
|
n := len(old)
|
||||||
|
x := old[n-1]
|
||||||
|
*h = old[:n-1]
|
||||||
|
return x
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testEntry creates a ServiceLogEntry for testing.
|
||||||
|
func testEntry(stream api.LogStreamType, ts time.Time, msg string) api.ServiceLogEntry {
|
||||||
|
return api.ServiceLogEntry{
|
||||||
|
ContainerLogEntry: api.ContainerLogEntry{
|
||||||
|
Stream: stream,
|
||||||
|
Timestamp: ts,
|
||||||
|
Message: []byte(msg),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectEntries collects up to maxCount entries from the channel or until it is closed.
|
||||||
|
func collectEntries(t *testing.T, ch <-chan api.ServiceLogEntry, maxCount int) []api.ServiceLogEntry {
|
||||||
|
t.Helper()
|
||||||
|
var entries []api.ServiceLogEntry
|
||||||
|
|
||||||
|
for i := 0; i < maxCount || maxCount <= 0; i++ {
|
||||||
|
select {
|
||||||
|
case e, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
require.FailNow(t, "timed out waiting for log entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogMerger_EmptyStreams(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
merger := NewLogMerger(nil, LogMergerOptions{})
|
||||||
|
output := merger.Stream()
|
||||||
|
|
||||||
|
_, ok := <-output
|
||||||
|
assert.False(t, ok, "output channel should be closed for empty streams")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogMerger_SingleStream(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ch := make(chan api.ServiceLogEntry, 10)
|
||||||
|
merger := NewLogMerger([]<-chan api.ServiceLogEntry{ch}, LogMergerOptions{})
|
||||||
|
output := merger.Stream()
|
||||||
|
|
||||||
|
t1 := time.Now()
|
||||||
|
t2 := t1.Add(time.Second)
|
||||||
|
|
||||||
|
ch <- testEntry(api.LogStreamStdout, t1, "first")
|
||||||
|
ch <- testEntry(api.LogStreamStdout, t2, "second")
|
||||||
|
|
||||||
|
results := collectEntries(t, output, 2)
|
||||||
|
|
||||||
|
require.Len(t, results, 2)
|
||||||
|
assert.Equal(t, "first", string(results[0].Message))
|
||||||
|
assert.Equal(t, "second", string(results[1].Message))
|
||||||
|
|
||||||
|
close(ch)
|
||||||
|
|
||||||
|
results = collectEntries(t, output, 0)
|
||||||
|
assert.Len(t, results, 0, "no entries expected after close")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogMerger_PreservesData(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ch := make(chan api.ServiceLogEntry, 10)
|
||||||
|
merger := NewLogMerger([]<-chan api.ServiceLogEntry{ch}, LogMergerOptions{})
|
||||||
|
output := merger.Stream()
|
||||||
|
|
||||||
|
metadata := api.ServiceLogEntryMetadata{
|
||||||
|
ServiceID: "svc-123",
|
||||||
|
ServiceName: "my-service",
|
||||||
|
MachineID: "machine-456",
|
||||||
|
MachineName: "machine-1",
|
||||||
|
}
|
||||||
|
|
||||||
|
e := api.ServiceLogEntry{
|
||||||
|
Metadata: metadata,
|
||||||
|
ContainerLogEntry: api.ContainerLogEntry{
|
||||||
|
Stream: api.LogStreamStdout,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Message: []byte("test"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch <- e
|
||||||
|
close(ch)
|
||||||
|
|
||||||
|
results := collectEntries(t, output, 0)
|
||||||
|
require.Len(t, results, 1)
|
||||||
|
assert.Equal(t, e, results[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogMerger_BasicMerge(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ch1 := make(chan api.ServiceLogEntry, 10)
|
||||||
|
ch2 := make(chan api.ServiceLogEntry, 10)
|
||||||
|
|
||||||
|
merger := NewLogMerger([]<-chan api.ServiceLogEntry{ch1, ch2}, LogMergerOptions{})
|
||||||
|
output := merger.Stream()
|
||||||
|
|
||||||
|
t1 := time.Now()
|
||||||
|
t2 := t1.Add(time.Second)
|
||||||
|
t3 := t1.Add(2 * time.Second)
|
||||||
|
t4 := t1.Add(3 * time.Second)
|
||||||
|
|
||||||
|
// Send interleaved entries from both streams.
|
||||||
|
ch1 <- testEntry(api.LogStreamStdout, t1, "ch1-first")
|
||||||
|
ch2 <- testEntry(api.LogStreamStdout, t2, "ch2-first")
|
||||||
|
ch1 <- testEntry(api.LogStreamStdout, t3, "ch1-second")
|
||||||
|
ch2 <- testEntry(api.LogStreamStdout, t4, "ch2-second")
|
||||||
|
|
||||||
|
// Send heartbeats to advance watermark past all entries.
|
||||||
|
t5 := t1.Add(4 * time.Second)
|
||||||
|
ch1 <- testEntry(api.LogStreamHeartbeat, t5, "")
|
||||||
|
ch2 <- testEntry(api.LogStreamHeartbeat, t5, "")
|
||||||
|
|
||||||
|
results := collectEntries(t, output, 4)
|
||||||
|
require.Len(t, results, 4)
|
||||||
|
assert.Equal(t, "ch1-first", string(results[0].Message))
|
||||||
|
assert.Equal(t, "ch2-first", string(results[1].Message))
|
||||||
|
assert.Equal(t, "ch1-second", string(results[2].Message))
|
||||||
|
assert.Equal(t, "ch2-second", string(results[3].Message))
|
||||||
|
|
||||||
|
close(ch1)
|
||||||
|
close(ch2)
|
||||||
|
|
||||||
|
results = collectEntries(t, output, 0)
|
||||||
|
assert.Len(t, results, 1, "one debounced heartbeat expected after close")
|
||||||
|
assert.Equal(t, api.LogStreamHeartbeat, results[0].Stream)
|
||||||
|
assert.Equal(t, t5, results[0].Timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogMerger_HeartbeatAdvancesWatermark(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ch1 := make(chan api.ServiceLogEntry, 10)
|
||||||
|
ch2 := make(chan api.ServiceLogEntry, 10)
|
||||||
|
|
||||||
|
merger := NewLogMerger([]<-chan api.ServiceLogEntry{ch1, ch2}, LogMergerOptions{})
|
||||||
|
output := merger.Stream()
|
||||||
|
|
||||||
|
t1 := time.Now()
|
||||||
|
t2 := t1.Add(time.Second)
|
||||||
|
t3 := t1.Add(2 * time.Second)
|
||||||
|
|
||||||
|
// Stream 1 sends a log entry.
|
||||||
|
ch1 <- testEntry(api.LogStreamStdout, t1, "ch1-first")
|
||||||
|
ch1 <- testEntry(api.LogStreamStdout, t3, "ch1-second")
|
||||||
|
// Stream 2 is quiet but sends a heartbeat.
|
||||||
|
ch2 <- testEntry(api.LogStreamHeartbeat, t2, "")
|
||||||
|
|
||||||
|
// Now stream 1's entry should be emitted because watermark is t1 (min of t1, t2).
|
||||||
|
results := collectEntries(t, output, 1)
|
||||||
|
require.Len(t, results, 1)
|
||||||
|
assert.Equal(t, "ch1-first", string(results[0].Message))
|
||||||
|
|
||||||
|
close(ch1)
|
||||||
|
close(ch2)
|
||||||
|
|
||||||
|
results = collectEntries(t, output, 0)
|
||||||
|
require.NotEmpty(t, results)
|
||||||
|
|
||||||
|
// Depending on timing, we may get a heartbeat before the second log entry.
|
||||||
|
require.LessOrEqual(t, len(results), 2, "one or two entries expected after close")
|
||||||
|
if len(results) == 2 {
|
||||||
|
assert.Equal(t, api.LogStreamHeartbeat, results[0].Stream)
|
||||||
|
assert.Equal(t, t2, results[0].Timestamp)
|
||||||
|
results = results[1:]
|
||||||
|
}
|
||||||
|
assert.Equal(t, "ch1-second", string(results[0].Message))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogMerger_ErrorForwarding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ch1 := make(chan api.ServiceLogEntry, 10)
|
||||||
|
ch2 := make(chan api.ServiceLogEntry, 10)
|
||||||
|
merger := NewLogMerger([]<-chan api.ServiceLogEntry{ch1, ch2}, LogMergerOptions{})
|
||||||
|
output := merger.Stream()
|
||||||
|
|
||||||
|
t1 := time.Now()
|
||||||
|
|
||||||
|
ch1 <- testEntry(api.LogStreamStdout, t1, "ch1-first")
|
||||||
|
// Send an error entry.
|
||||||
|
ch1 <- api.ServiceLogEntry{
|
||||||
|
ContainerLogEntry: api.ContainerLogEntry{
|
||||||
|
Err: assert.AnError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results := collectEntries(t, output, 1)
|
||||||
|
require.Len(t, results, 1, "expected error to emitted out of order")
|
||||||
|
assert.Equal(t, assert.AnError, results[0].Err)
|
||||||
|
|
||||||
|
close(ch1)
|
||||||
|
close(ch2)
|
||||||
|
|
||||||
|
results = collectEntries(t, output, 0)
|
||||||
|
assert.Len(t, results, 1)
|
||||||
|
assert.Equal(t, "ch1-first", string(results[0].Message))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogMerger_OutOfOrderSingleStream(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ch := make(chan api.ServiceLogEntry, 10)
|
||||||
|
merger := NewLogMerger([]<-chan api.ServiceLogEntry{ch}, LogMergerOptions{})
|
||||||
|
output := merger.Stream()
|
||||||
|
|
||||||
|
t1 := time.Now()
|
||||||
|
t2 := t1.Add(time.Second)
|
||||||
|
t3 := t1.Add(2 * time.Second)
|
||||||
|
t4 := t1.Add(3 * time.Second)
|
||||||
|
|
||||||
|
// Send entries out of order within the stream.
|
||||||
|
ch <- testEntry(api.LogStreamStdout, t3, "third")
|
||||||
|
ch <- testEntry(api.LogStreamStdout, t2, "second")
|
||||||
|
ch <- testEntry(api.LogStreamStdout, t4, "forth")
|
||||||
|
ch <- testEntry(api.LogStreamStdout, t1, "first")
|
||||||
|
|
||||||
|
results := collectEntries(t, output, 4)
|
||||||
|
require.Len(t, results, 4)
|
||||||
|
// Emitted in the same order as sent since no buffering/reordering is done within a single stream.
|
||||||
|
assert.Equal(t, "third", string(results[0].Message))
|
||||||
|
assert.Equal(t, "second", string(results[1].Message))
|
||||||
|
assert.Equal(t, "forth", string(results[2].Message))
|
||||||
|
assert.Equal(t, "first", string(results[3].Message))
|
||||||
|
|
||||||
|
close(ch)
|
||||||
|
|
||||||
|
results = collectEntries(t, output, 0)
|
||||||
|
assert.Len(t, results, 0, "no entries expected after close")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that entries from streams with different rates are merged correctly in chronological order.
|
||||||
|
func TestLogMerger_UnevenStreams(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
numFastEntries := logMergerMaxInFlightPerStream
|
||||||
|
|
||||||
|
// Use buffered channels to allow sends without blocking on receiver.
|
||||||
|
ch1 := make(chan api.ServiceLogEntry, numFastEntries+5)
|
||||||
|
ch2 := make(chan api.ServiceLogEntry, 10)
|
||||||
|
|
||||||
|
merger := NewLogMerger([]<-chan api.ServiceLogEntry{ch1, ch2}, LogMergerOptions{})
|
||||||
|
output := merger.Stream()
|
||||||
|
|
||||||
|
baseTime := time.Now()
|
||||||
|
|
||||||
|
// Stream 1 sends many entries quickly (0ms - 104ms).
|
||||||
|
for i := 0; i < numFastEntries; i++ {
|
||||||
|
ch1 <- testEntry(api.LogStreamStdout, baseTime.Add(time.Duration(i)*time.Millisecond), "fast")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream 2 sends entries that interleave with fast entries.
|
||||||
|
ch2 <- testEntry(api.LogStreamStdout, baseTime.Add(5*time.Millisecond), "slow")
|
||||||
|
ch2 <- testEntry(api.LogStreamStdout, baseTime.Add(50*time.Millisecond), "slow")
|
||||||
|
|
||||||
|
// Send heartbeats to advance watermark past all entries so they get emitted.
|
||||||
|
finalTime := baseTime.Add(time.Second)
|
||||||
|
ch1 <- testEntry(api.LogStreamHeartbeat, finalTime, "")
|
||||||
|
ch2 <- testEntry(api.LogStreamHeartbeat, finalTime.Add(10*time.Millisecond), "")
|
||||||
|
|
||||||
|
// Read exactly numFastEntries + 2 log entries.
|
||||||
|
expectedCount := numFastEntries + 2
|
||||||
|
results := collectEntries(t, output, expectedCount)
|
||||||
|
require.Len(t, results, expectedCount)
|
||||||
|
|
||||||
|
// Count entries from each stream.
|
||||||
|
fastCount := 0
|
||||||
|
slowCount := 0
|
||||||
|
for _, entry := range results {
|
||||||
|
switch string(entry.Message) {
|
||||||
|
case "fast":
|
||||||
|
fastCount++
|
||||||
|
case "slow":
|
||||||
|
slowCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, numFastEntries, fastCount)
|
||||||
|
assert.Equal(t, 2, slowCount)
|
||||||
|
|
||||||
|
// Verify entries are in chronological order.
|
||||||
|
for i := 1; i < len(results); i++ {
|
||||||
|
assert.False(t, results[i].Timestamp.Before(results[i-1].Timestamp),
|
||||||
|
"entry %d (ts=%v) should not be before entry %d (ts=%v)",
|
||||||
|
i, results[i].Timestamp, i-1, results[i-1].Timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(ch1)
|
||||||
|
close(ch2)
|
||||||
|
|
||||||
|
results = collectEntries(t, output, 0)
|
||||||
|
require.Len(t, results, 1, "one debounced heartbeat expected after close")
|
||||||
|
assert.Equal(t, api.LogStreamHeartbeat, results[0].Stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogMerger_StalledStreamExcludedFromWatermark(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ch1 := make(chan api.ServiceLogEntry, 10)
|
||||||
|
ch2 := make(chan api.ServiceLogEntry, 10)
|
||||||
|
|
||||||
|
stallTimeout := 100 * time.Millisecond
|
||||||
|
merger := NewLogMerger([]<-chan api.ServiceLogEntry{ch1, ch2}, LogMergerOptions{
|
||||||
|
StallTimeout: stallTimeout,
|
||||||
|
StallCheckInterval: 20 * time.Millisecond,
|
||||||
|
})
|
||||||
|
output := merger.Stream()
|
||||||
|
|
||||||
|
//// Collect all output entries in a separate goroutine.
|
||||||
|
//var results []api.ServiceLogEntry
|
||||||
|
//doneCollecting := make(chan struct{})
|
||||||
|
//go func() {
|
||||||
|
// for entry := range output {
|
||||||
|
// results = append(results, entry)
|
||||||
|
// }
|
||||||
|
// close(doneCollecting)
|
||||||
|
//}()
|
||||||
|
|
||||||
|
t1 := time.Now()
|
||||||
|
t2 := t1.Add(time.Second)
|
||||||
|
t3 := t1.Add(2 * time.Second)
|
||||||
|
t4 := t1.Add(3 * time.Second)
|
||||||
|
|
||||||
|
e1 := testEntry(api.LogStreamStdout, t1, "ch1-entry")
|
||||||
|
e1.Metadata = api.ServiceLogEntryMetadata{
|
||||||
|
ServiceID: "ch1-svc1",
|
||||||
|
ServiceName: "ch1-svcName",
|
||||||
|
ContainerID: "ch1-ctr1",
|
||||||
|
MachineID: "ch1-machine1",
|
||||||
|
MachineName: "ch1-machineName",
|
||||||
|
}
|
||||||
|
|
||||||
|
ch1 <- e1
|
||||||
|
ch2 <- testEntry(api.LogStreamStdout, t2, "ch2-first")
|
||||||
|
|
||||||
|
// Watermark is t1 now so ch1's entry could be collected.
|
||||||
|
results := collectEntries(t, output, 1)
|
||||||
|
require.Len(t, results, 1)
|
||||||
|
assert.Equal(t, "ch1-entry", string(results[0].Message))
|
||||||
|
|
||||||
|
// Keep pushing to stream 2 so that it does not stall. But it can't emit anything because watermark is stuck at t1.
|
||||||
|
time.Sleep(stallTimeout / 2)
|
||||||
|
ch2 <- testEntry(api.LogStreamStdout, t3, "ch2-second")
|
||||||
|
|
||||||
|
// 1/2 + 2/3 = 7/6 > 1, so stream 1 should be considered stalled now.
|
||||||
|
time.Sleep(stallTimeout / 3 * 2)
|
||||||
|
ch2 <- testEntry(api.LogStreamStdout, t4, "ch2-third")
|
||||||
|
|
||||||
|
results = collectEntries(t, output, 1)
|
||||||
|
require.ErrorIs(t, results[0].Err, api.ErrLogStreamStalled)
|
||||||
|
assert.Equal(t, e1.Metadata, results[0].Metadata, "stalled entry should have stream metadata")
|
||||||
|
|
||||||
|
// Now that stream 1 is marked as stalled and ignored, all in-flight entries from stream 2 are emitted.
|
||||||
|
results = collectEntries(t, output, 3)
|
||||||
|
require.Len(t, results, 3)
|
||||||
|
assert.Equal(t, "ch2-first", string(results[0].Message))
|
||||||
|
assert.Equal(t, "ch2-second", string(results[1].Message))
|
||||||
|
assert.Equal(t, "ch2-third", string(results[2].Message))
|
||||||
|
|
||||||
|
close(ch1)
|
||||||
|
close(ch2)
|
||||||
|
|
||||||
|
results = collectEntries(t, output, 0)
|
||||||
|
assert.Len(t, results, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceLogs streams log entries from all service containers in chronological order based on timestamps.
|
||||||
|
// Keep in mind that perfect ordering of log events across multiple machines can't be guaranteed due to the
|
||||||
|
// imperfection of physical clocks or potential clock skew between machines.
|
||||||
|
// It uses a low watermark algorithm to ensure proper ordering across multiple machines.
|
||||||
|
// Heartbeat entries from the server advance the watermark to enable timely emission of buffered logs.
|
||||||
|
func (cli *Client) ServiceLogs(
|
||||||
|
ctx context.Context, serviceNameOrID string, opts api.ServiceLogsOptions,
|
||||||
|
) (api.Service, <-chan api.ServiceLogEntry, error) {
|
||||||
|
svc, err := cli.InspectService(ctx, serviceNameOrID)
|
||||||
|
if err != nil {
|
||||||
|
return svc, nil, fmt.Errorf("inspect service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(svc.Containers) == 0 {
|
||||||
|
return svc, nil, fmt.Errorf("no containers found for service: %s", serviceNameOrID)
|
||||||
|
}
|
||||||
|
|
||||||
|
machines, err := cli.ListMachines(ctx, &api.MachineFilter{
|
||||||
|
NamesOrIDs: opts.Machines,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return svc, nil, fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrStreams := make([]<-chan api.ServiceLogEntry, 0, len(svc.Containers))
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
// Skip containers not running on the specified machines.
|
||||||
|
m := machines.FindByNameOrID(ctr.MachineID)
|
||||||
|
if len(opts.Machines) > 0 && m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Machine name for ServiceLogEntry metadata and friendlier error message.
|
||||||
|
machineName := ctr.MachineID
|
||||||
|
if m != nil {
|
||||||
|
machineName = m.Machine.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := cli.ContainerLogs(ctx, ctr.MachineID, ctr.Container.ID, opts)
|
||||||
|
if err != nil {
|
||||||
|
return svc, nil, fmt.Errorf("stream logs from service container '%s' on machine '%s': %w",
|
||||||
|
stringid.TruncateID(ctr.Container.ID), machineName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich log entries from the container with service metadata.
|
||||||
|
metadata := api.ServiceLogEntryMetadata{
|
||||||
|
ServiceID: svc.ID,
|
||||||
|
ServiceName: svc.Name,
|
||||||
|
ContainerID: ctr.Container.ID,
|
||||||
|
MachineID: ctr.MachineID,
|
||||||
|
MachineName: machineName,
|
||||||
|
}
|
||||||
|
enrichedStream := logsStreamWithServiceMetadata(stream, metadata)
|
||||||
|
ctrStreams = append(ctrStreams, enrichedStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ctrStreams) == 0 {
|
||||||
|
return svc, nil, errors.New("no service containers found on the specified machine(s)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the log merger to combine streams from all containers in chronological order.
|
||||||
|
merger := NewLogMerger(ctrStreams, DefaultLogMergerOptions)
|
||||||
|
mergedStream := merger.Stream()
|
||||||
|
|
||||||
|
return svc, mergedStream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
if !opts.Follow && opts.Tail == 0 {
|
||||||
|
// If not following and tail is 0, set tail to -1 to return all logs.
|
||||||
|
// Otherwise, no logs will be returned at all.
|
||||||
|
req.Tail = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := cli.Docker.GRPCClient.ContainerLogs(proxyCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan api.ContainerLogEntry)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
|
||||||
|
for {
|
||||||
|
pbEntry, err := stream.Recv()
|
||||||
|
if err == io.EOF {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
ch <- api.ContainerLogEntry{
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := api.ContainerLogEntry{
|
||||||
|
Stream: api.LogStreamTypeFromProto(pbEntry.Stream),
|
||||||
|
Message: pbEntry.Message,
|
||||||
|
Timestamp: pbEntry.Timestamp.AsTime(),
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ch <- entry:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logsStreamWithServiceMetadata wraps a container logs stream and enriches each log entry with service metadata.
|
||||||
|
func logsStreamWithServiceMetadata(
|
||||||
|
stream <-chan api.ContainerLogEntry, metadata api.ServiceLogEntryMetadata,
|
||||||
|
) <-chan api.ServiceLogEntry {
|
||||||
|
out := make(chan api.ServiceLogEntry)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for entry := range stream {
|
||||||
|
out <- api.ServiceLogEntry{
|
||||||
|
Metadata: metadata,
|
||||||
|
ContainerLogEntry: entry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(out)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
+30
-27
@@ -2,7 +2,8 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"slices"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
@@ -32,17 +33,39 @@ func (cli *Client) ListMachines(ctx context.Context, filter *api.MachineFilter)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
machines := api.MachineMembersList(resp.Machines)
|
||||||
|
|
||||||
machines := resp.Machines
|
if filter == nil {
|
||||||
|
return machines, nil
|
||||||
|
}
|
||||||
|
|
||||||
if filter != nil {
|
// Apply the filter.
|
||||||
var matchedMachines api.MachineMembersList
|
if len(filter.NamesOrIDs) > 0 {
|
||||||
|
var matched api.MachineMembersList
|
||||||
|
var notFound []string
|
||||||
|
|
||||||
|
for _, nameOrID := range filter.NamesOrIDs {
|
||||||
|
if m := machines.FindByNameOrID(nameOrID); m != nil {
|
||||||
|
matched = append(matched, m)
|
||||||
|
} else {
|
||||||
|
notFound = append(notFound, nameOrID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
machines = matched
|
||||||
|
|
||||||
|
if len(notFound) > 0 {
|
||||||
|
return nil, fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Available {
|
||||||
|
var available api.MachineMembersList
|
||||||
for _, m := range machines {
|
for _, m := range machines {
|
||||||
if MachineMatchesFilter(m, filter) {
|
if m.State != pb.MachineMember_DOWN {
|
||||||
matchedMachines = append(matchedMachines, m)
|
available = append(available, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
machines = matchedMachines
|
machines = available
|
||||||
}
|
}
|
||||||
|
|
||||||
return machines, nil
|
return machines, nil
|
||||||
@@ -76,23 +99,3 @@ func (cli *Client) RenameMachine(ctx context.Context, nameOrID, newName string)
|
|||||||
|
|
||||||
return cli.UpdateMachine(ctx, req)
|
return cli.UpdateMachine(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func MachineMatchesFilter(machine *pb.MachineMember, filter *api.MachineFilter) bool {
|
|
||||||
if filter == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter.Available && machine.State == pb.MachineMember_DOWN {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(filter.NamesOrIDs) > 0 {
|
|
||||||
if !slices.ContainsFunc(filter.NamesOrIDs, func(nameOrID string) bool {
|
|
||||||
return machine.Machine.Id == nameOrID || machine.Machine.Name == nameOrID
|
|
||||||
}) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -273,6 +273,80 @@ func (cli *Client) RemoveService(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StopService stops all containers on all machines that belong to the specified service.
|
||||||
|
// The id parameter can be either a service ID or name.
|
||||||
|
func (cli *Client) StopService(ctx context.Context, id string, opts container.StopOptions) error {
|
||||||
|
svc, err := cli.InspectService(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
errCh := make(chan error)
|
||||||
|
|
||||||
|
// Stop all containers on all machines that belong to the service.
|
||||||
|
for _, mc := range svc.Containers {
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
err := cli.StopContainer(ctx, svc.ID, mc.Container.ID, opts)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- fmt.Errorf("stop container '%s': %w", mc.Container.ID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = nil
|
||||||
|
for e := range errCh {
|
||||||
|
err = errors.Join(err, e)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartService starts all containers on all machines that belong to the specified service.
|
||||||
|
// The id parameter can be either a service ID or name.
|
||||||
|
func (cli *Client) StartService(ctx context.Context, id string) error {
|
||||||
|
svc, err := cli.InspectService(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
errCh := make(chan error)
|
||||||
|
|
||||||
|
// Start all containers on all machines that belong to the service.
|
||||||
|
for _, mc := range svc.Containers {
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
err := cli.StartContainer(ctx, svc.ID, mc.Container.ID)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- fmt.Errorf("start container '%s': %w", mc.Container.ID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = nil
|
||||||
|
for e := range errCh {
|
||||||
|
err = errors.Join(err, e)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ListServices returns a list of all services and their containers.
|
// ListServices returns a list of all services and their containers.
|
||||||
func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
|
func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
|
||||||
machines, err := cli.ListMachines(ctx, nil)
|
machines, err := cli.ListMachines(ctx, nil)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func (cli *Client) ListVolumes(ctx context.Context, filter *api.VolumeFilter) ([
|
|||||||
proxyMachines = filter.Machines
|
proxyMachines = filter.Machines
|
||||||
}
|
}
|
||||||
|
|
||||||
listCtx, machines, err := api.ProxyMachinesContext(ctx, cli, proxyMachines)
|
listCtx, machines, err := cli.ProxyMachinesContext(ctx, proxyMachines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create request context to broadcast to all machines: %w", err)
|
return nil, fmt.Errorf("create request context to broadcast to all machines: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-2
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# If set to 'true', only install the packages and dependencies, without running, reloading, or
|
||||||
|
# restarting services or systemd.
|
||||||
|
INSTALL_ONLY=${INSTALL_ONLY:-false}
|
||||||
|
|
||||||
INSTALL_BIN_DIR=${INSTALL_BIN_DIR:-/usr/local/bin}
|
INSTALL_BIN_DIR=${INSTALL_BIN_DIR:-/usr/local/bin}
|
||||||
INSTALL_SYSTEMD_DIR=${INSTALL_SYSTEMD_DIR:-/etc/systemd/system}
|
INSTALL_SYSTEMD_DIR=${INSTALL_SYSTEMD_DIR:-/etc/systemd/system}
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ verify_system() {
|
|||||||
Your system architecture ($arch) is not supported."
|
Your system architecture ($arch) is not supported."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -d /run/systemd/system ]]; then
|
if [[ ! -d /run/systemd/system && "${INSTALL_ONLY}" != "true" ]]; then
|
||||||
error "Cannot find systemd to use as a service manager for the Uncloud machine daemon. \
|
error "Cannot find systemd to use as a service manager for the Uncloud machine daemon. \
|
||||||
Uncloud supports only systemd-based Linux systems for now."
|
Uncloud supports only systemd-based Linux systems for now."
|
||||||
fi
|
fi
|
||||||
@@ -66,9 +70,14 @@ Uncloud supports only systemd-based Linux systems for now."
|
|||||||
install_docker() {
|
install_docker() {
|
||||||
if command_exists dockerd; then
|
if command_exists dockerd; then
|
||||||
log "✓ Docker is already installed."
|
log "✓ Docker is already installed."
|
||||||
|
DOCKER_ALREADY_INSTALLED=true
|
||||||
|
|
||||||
|
if [[ "${INSTALL_ONLY}" == "true" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
docker version
|
docker version
|
||||||
|
|
||||||
DOCKER_ALREADY_INSTALLED=true
|
|
||||||
# Check if the installed Docker configured to use the containerd image store.
|
# Check if the installed Docker configured to use the containerd image store.
|
||||||
local driver_status
|
local driver_status
|
||||||
driver_status=$(docker info -f '{{ .DriverStatus }}' 2>/dev/null)
|
driver_status=$(docker info -f '{{ .DriverStatus }}' 2>/dev/null)
|
||||||
@@ -93,7 +102,9 @@ install_docker() {
|
|||||||
log "⏳ Configuring Docker daemon (${DOCKER_DAEMON_CONFIG_FILE}) to optimise it for Uncloud..."
|
log "⏳ Configuring Docker daemon (${DOCKER_DAEMON_CONFIG_FILE}) to optimise it for Uncloud..."
|
||||||
echo "${DOCKER_DAEMON_CONFIG}" > "${DOCKER_DAEMON_CONFIG_FILE}"
|
echo "${DOCKER_DAEMON_CONFIG}" > "${DOCKER_DAEMON_CONFIG_FILE}"
|
||||||
|
|
||||||
|
if [[ "${INSTALL_ONLY}" != "true" ]]; then
|
||||||
systemctl restart docker
|
systemctl restart docker
|
||||||
|
fi
|
||||||
|
|
||||||
log "✓ Docker installed and configured successfully."
|
log "✓ Docker installed and configured successfully."
|
||||||
}
|
}
|
||||||
@@ -215,8 +226,11 @@ WantedBy=multi-user.target
|
|||||||
EOF
|
EOF
|
||||||
log "✓ Systemd unit file created: ${uncloud_service_path}"
|
log "✓ Systemd unit file created: ${uncloud_service_path}"
|
||||||
|
|
||||||
|
|
||||||
|
if [[ "${INSTALL_ONLY}" != "true" ]]; then
|
||||||
# Reload systemd to recognize the new or updated unit file.
|
# Reload systemd to recognize the new or updated unit file.
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
systemctl enable uncloud.service
|
systemctl enable uncloud.service
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,11 +298,17 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|||||||
EOF
|
EOF
|
||||||
log "✓ Systemd unit file created: ${corrosion_service_path}"
|
log "✓ Systemd unit file created: ${corrosion_service_path}"
|
||||||
|
|
||||||
|
if [[ "${INSTALL_ONLY}" != "true" ]]; then
|
||||||
# Reload systemd to recognize the new unit file
|
# Reload systemd to recognize the new unit file
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
start_uncloud() {
|
start_uncloud() {
|
||||||
|
if [[ "${INSTALL_ONLY}" == "true" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
log "⏳ Starting Uncloud machine daemon (uncloud.service)..."
|
log "⏳ Starting Uncloud machine daemon (uncloud.service)..."
|
||||||
systemctl restart uncloud.service
|
systemctl restart uncloud.service
|
||||||
log "✓ Uncloud machine daemon started."
|
log "✓ Uncloud machine daemon started."
|
||||||
|
|||||||
@@ -20,12 +20,8 @@ print_manual_install() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetch_latest_version() {
|
fetch_latest_version() {
|
||||||
api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest"
|
latest_url="https://github.com/${GITHUB_REPO}/releases/latest"
|
||||||
auth_header=""
|
VERSION=$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$latest_url" | grep -o 'tag/[^/]*$' | cut -d'/' -f2)
|
||||||
if [ -n "$GITHUB_TOKEN" ]; then
|
|
||||||
auth_header="Authorization: Bearer $GITHUB_TOKEN"
|
|
||||||
fi
|
|
||||||
VERSION=$(curl -fsSL -H "$auth_header" "$api_url" | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4)
|
|
||||||
if [ -z "$VERSION" ]; then
|
if [ -z "$VERSION" ]; then
|
||||||
echo "Failed to fetch the latest version from GitHub."
|
echo "Failed to fetch the latest version from GitHub."
|
||||||
print_manual_install
|
print_manual_install
|
||||||
|
|||||||
@@ -533,4 +533,29 @@ func TestComposeDeployment(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, serviceMachines.ToSlice(), []string{c.Machines[0].ID, c.Machines[2].ID},
|
assert.ElementsMatch(t, serviceMachines.ToSlice(), []string{c.Machines[0].ID, c.Machines[2].ID},
|
||||||
"Service containers should be on machines 1 and 3 from comma-separated list")
|
"Service containers should be on machines 1 and 3 from comma-separated list")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Catches regression: https://github.com/psviderski/uncloud/issues/176
|
||||||
|
t.Run("plan new deployment with volumes and recreate strategy", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
project, err := compose.LoadProjectFromContent(ctx, `
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
deployment, err := compose.NewDeploymentWithStrategy(ctx, cli, project,
|
||||||
|
&deploy.RollingStrategy{ForceRecreate: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
plan, err := deployment.Plan(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, plan.Operations, 2, "Expected 1 volume creation and 1 service to deploy")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-97
@@ -2,14 +2,11 @@ package e2e
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
|
||||||
dockerclient "github.com/docker/docker/client"
|
|
||||||
"github.com/psviderski/uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -68,94 +65,52 @@ func TestInternalDNS(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
}, 30*time.Second, 1*time.Second, "Service should be deployed and running on all machines")
|
}, 30*time.Second, 1*time.Second, "Service should be deployed and running on all machines")
|
||||||
|
|
||||||
// Get Docker client to access ucind machine containers (shared across all DNS tests)
|
// Deploy a single "network-multitool" service to be used for all DNS queries
|
||||||
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
queryServiceName := "dns-query-service"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := cli.RemoveService(ctx, queryServiceName)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "not found") {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
// Helper function to create DNS query service spec
|
})
|
||||||
createDNSQuerySpec := func(name, dnsQuery, outputFile string) api.ServiceSpec {
|
querySvcSpec := api.ServiceSpec{
|
||||||
return api.ServiceSpec{
|
Name: queryServiceName,
|
||||||
Name: name,
|
|
||||||
Mode: api.ServiceModeReplicated,
|
Mode: api.ServiceModeReplicated,
|
||||||
|
Replicas: 1,
|
||||||
|
Placement: api.Placement{
|
||||||
|
Machines: []string{c.Machines[0].Name},
|
||||||
|
},
|
||||||
Container: api.ContainerSpec{
|
Container: api.ContainerSpec{
|
||||||
Image: "wbitt/network-multitool",
|
Image: "wbitt/network-multitool",
|
||||||
Command: []string{"sh", "-c", fmt.Sprintf("nslookup %s > %s 2>&1 && echo 'DNS query completed' && sleep infinity",
|
Command: []string{"sleep", "infinity"},
|
||||||
dnsQuery, outputFile)},
|
|
||||||
VolumeMounts: []api.VolumeMount{
|
|
||||||
{
|
|
||||||
VolumeName: "host-tmp",
|
|
||||||
ContainerPath: "/tmp",
|
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
Volumes: []api.VolumeSpec{
|
|
||||||
{
|
|
||||||
Name: "host-tmp",
|
|
||||||
Type: "bind",
|
|
||||||
BindOptions: &api.BindOptions{
|
|
||||||
HostPath: "/tmp",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Replicas: 1,
|
|
||||||
}
|
}
|
||||||
}
|
_, err = cli.RunService(ctx, querySvcSpec)
|
||||||
|
|
||||||
// Helper function to run DNS query and get results
|
|
||||||
runDNSQuery := func(t *testing.T, serviceName, dnsQuery, outputFile string) string {
|
|
||||||
spec := createDNSQuerySpec(serviceName, dnsQuery, outputFile)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
cli.RemoveService(ctx, spec.Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Run the DNS query service
|
|
||||||
resp, err := cli.RunService(ctx, spec)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Wait for the DNS query to complete
|
// Wait for the query service to be deployed
|
||||||
var querySvc api.Service
|
var querySvc api.Service
|
||||||
require.Eventually(t, func() bool {
|
require.Eventually(t, func() bool {
|
||||||
querySvc, err = cli.InspectService(ctx, resp.ID)
|
querySvc, err = cli.InspectService(ctx, queryServiceName)
|
||||||
if err != nil || len(querySvc.Containers) == 0 {
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Should have 1 container and it should be running
|
||||||
|
if len(querySvc.Containers) != 1 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return querySvc.Containers[0].Container.State.Status == "running"
|
return querySvc.Containers[0].Container.State.Status == "running"
|
||||||
}, 30*time.Second, 1*time.Second, "DNS query container should be running")
|
}, 30*time.Second, 1*time.Second, "Query service should be deployed and running")
|
||||||
|
queryContainer := querySvc.Containers[0]
|
||||||
|
|
||||||
// Give some time for DNS query to complete and write to file
|
// Run nslookup for given query
|
||||||
time.Sleep(1 * time.Second)
|
runNslookup := func(t *testing.T, dnsQuery string) string {
|
||||||
|
dnsOutput, err := execInContainerAndReadOutput(
|
||||||
// Find which ucind machine the DNS query container is running on
|
t, ctx, cli, queryServiceName, queryContainer.Container.ID,
|
||||||
ctr := querySvc.Containers[0]
|
[]string{"nslookup", dnsQuery},
|
||||||
machineID := ctr.MachineID
|
)
|
||||||
|
|
||||||
// Get the ucind machine container name for this machine
|
|
||||||
var ucindMachineName string
|
|
||||||
for _, machine := range c.Machines {
|
|
||||||
if machine.ID == machineID {
|
|
||||||
ucindMachineName = machine.ContainerName
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.NotEmpty(t, ucindMachineName, "Should find ucind machine container name")
|
|
||||||
|
|
||||||
// Read the DNS results from the ucind machine's filesystem
|
|
||||||
exec, err := dockerCli.ContainerExecCreate(ctx, ucindMachineName, container.ExecOptions{
|
|
||||||
Cmd: []string{"cat", outputFile},
|
|
||||||
AttachStdout: true,
|
|
||||||
AttachStderr: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
return dnsOutput
|
||||||
resp2, err := dockerCli.ContainerExecAttach(ctx, exec.ID, container.ExecAttachOptions{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp2.Close()
|
|
||||||
|
|
||||||
// Read the DNS output
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
n, _ := resp2.Reader.Read(buf)
|
|
||||||
return string(buf[:n])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to verify DNS output doesn't contain errors
|
// Helper function to verify DNS output doesn't contain errors
|
||||||
@@ -166,7 +121,7 @@ func TestInternalDNS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Run("service name resolves to all container IPs", func(t *testing.T) {
|
t.Run("service name resolves to all container IPs", func(t *testing.T) {
|
||||||
dnsOutput := runDNSQuery(t, "dns-test-service-name", serviceName+".internal", "/tmp/dns_result.txt")
|
dnsOutput := runNslookup(t, serviceName+".internal")
|
||||||
t.Logf("DNS query output:\n%s", dnsOutput)
|
t.Logf("DNS query output:\n%s", dnsOutput)
|
||||||
|
|
||||||
// Verify that all service container IPs are in the DNS response
|
// Verify that all service container IPs are in the DNS response
|
||||||
@@ -181,15 +136,14 @@ func TestInternalDNS(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("machine-specific service DNS lookups", func(t *testing.T) {
|
t.Run("machine-specific service DNS lookups", func(t *testing.T) {
|
||||||
// Test the new <machine-id>.m.<service-name>.internal DNS feature
|
// Test the new <machine-id>.m.<service-name>.internal DNS feature
|
||||||
for i, targetContainer := range svc.Containers {
|
for _, targetContainer := range svc.Containers {
|
||||||
targetMachineID := targetContainer.MachineID
|
targetMachineID := targetContainer.MachineID
|
||||||
targetContainerIP := targetContainer.Container.UncloudNetworkIP().String()
|
targetContainerIP := targetContainer.Container.UncloudNetworkIP().String()
|
||||||
|
|
||||||
// Construct the machine-specific DNS name
|
// Construct the machine-specific DNS name
|
||||||
machineSpecificDNS := targetMachineID + ".m." + serviceName + ".internal"
|
machineSpecificDNS := targetMachineID + ".m." + serviceName + ".internal"
|
||||||
outputFile := fmt.Sprintf("/tmp/dns_result_machine_%d.txt", i)
|
|
||||||
|
|
||||||
dnsOutput := runDNSQuery(t, fmt.Sprintf("dns-test-machine-%d", i), machineSpecificDNS, outputFile)
|
dnsOutput := runNslookup(t, machineSpecificDNS)
|
||||||
t.Logf("Machine-specific DNS query output for %s:\n%s", machineSpecificDNS, dnsOutput)
|
t.Logf("Machine-specific DNS query output for %s:\n%s", machineSpecificDNS, dnsOutput)
|
||||||
|
|
||||||
// Verify that the specific container IP is returned
|
// Verify that the specific container IP is returned
|
||||||
@@ -213,7 +167,7 @@ func TestInternalDNS(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("service ID DNS lookup", func(t *testing.T) {
|
t.Run("service ID DNS lookup", func(t *testing.T) {
|
||||||
// Test that service ID also resolves (existing functionality)
|
// Test that service ID also resolves (existing functionality)
|
||||||
dnsOutput := runDNSQuery(t, "dns-test-service-id", svc.ID+".internal", "/tmp/dns_result_service_id.txt")
|
dnsOutput := runNslookup(t, svc.ID+".internal")
|
||||||
t.Logf("Service ID DNS query output:\n%s", dnsOutput)
|
t.Logf("Service ID DNS query output:\n%s", dnsOutput)
|
||||||
|
|
||||||
// Verify that all service container IPs are in the DNS response
|
// Verify that all service container IPs are in the DNS response
|
||||||
@@ -227,36 +181,35 @@ func TestInternalDNS(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("nearest mode prioritizes local subnet IPs", func(t *testing.T) {
|
t.Run("nearest mode prioritizes local subnet IPs", func(t *testing.T) {
|
||||||
// Test the "nearest" mode which should sort local subnet IPs first
|
// Find the service container on the same machine as the query container.
|
||||||
dnsOutput := runDNSQuery(t, "dns-test-nearest", "nearest."+serviceName+".internal", "/tmp/dns_result_nearest.txt")
|
|
||||||
t.Logf("Nearest mode DNS query output:\n%s", dnsOutput)
|
|
||||||
|
|
||||||
// Find which machine ran the query
|
|
||||||
querySvc, err := cli.InspectService(ctx, "dns-test-nearest")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEmpty(t, querySvc.Containers, "Query service should have a container")
|
|
||||||
queryMachineID := querySvc.Containers[0].MachineID
|
|
||||||
|
|
||||||
// Find the local container IP (on the same machine as the query)
|
|
||||||
var localIP string
|
var localIP string
|
||||||
for _, ctr := range svc.Containers {
|
for _, ctr := range svc.Containers {
|
||||||
if ctr.MachineID == queryMachineID {
|
if ctr.MachineID == queryContainer.MachineID {
|
||||||
localIP = ctr.Container.UncloudNetworkIP().String()
|
localIP = ctr.Container.UncloudNetworkIP().String()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
require.NotEmpty(t, localIP, "Should find local container IP on query machine %s", queryMachineID)
|
require.NotEmpty(t, localIP, "Should find local container IP on query machine %s", queryContainer.MachineID)
|
||||||
|
|
||||||
// Extract the first IP address from the DNS output using regex
|
// We will extract the first IP address from the DNS output using a regex.
|
||||||
// Pattern matches: "Name: nearest.test-dns-service.internal" followed by "Address: X.X.X.X"
|
// Pattern matches "Name: nearest.test-dns-service.internal" followed by "Address: X.X.X.X".
|
||||||
re := regexp.MustCompile(`(?m)Name:\s+[\w\.\-]+\s+Address:\s+([\d\.]+)`)
|
re := regexp.MustCompile(`(?m)Name:\s+[\w\.\-]+\s+Address:\s+([\d\.]+)`)
|
||||||
|
|
||||||
|
// Test the "nearest" mode which should sort local subnet IPs first.
|
||||||
|
// The default behavior randomizes the order, so run it a few times
|
||||||
|
// to reduce the chance we're just getting lucky with the order.
|
||||||
|
for range 5 {
|
||||||
|
dnsOutput := runNslookup(t, "nearest."+serviceName+".internal")
|
||||||
|
t.Logf("Nearest mode DNS query output:\n%s", dnsOutput)
|
||||||
|
|
||||||
matches := re.FindStringSubmatch(dnsOutput)
|
matches := re.FindStringSubmatch(dnsOutput)
|
||||||
require.Len(t, matches, 2, "Should find Name's Address in DNS output")
|
require.Len(t, matches, 2, "Should find Name's Address in DNS output")
|
||||||
firstIP := matches[1]
|
firstIP := matches[1]
|
||||||
assert.Equal(t, localIP, firstIP,
|
assert.Equal(t, localIP, firstIP,
|
||||||
"Nearest mode should return local subnet IP first (query machine: %s, local IP: %s, first DNS result: %s)",
|
"Nearest mode should return local subnet IP first (query machine: %s, local IP: %s, first DNS result: %s)",
|
||||||
queryMachineID, localIP, firstIP)
|
queryContainer.MachineID, localIP, firstIP)
|
||||||
|
|
||||||
assertNoDNSErrors(t, dnsOutput)
|
assertNoDNSErrors(t, dnsOutput)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ func deployTestService(t *testing.T, ctx context.Context, cli *client.Client, na
|
|||||||
}
|
}
|
||||||
|
|
||||||
deployment := cli.NewDeployment(spec, nil)
|
deployment := cli.NewDeployment(spec, nil)
|
||||||
err := deployment.Validate(ctx)
|
_, err := deployment.Run(ctx)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = deployment.Run(ctx)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Wait for all replicas to be running
|
// Wait for all replicas to be running
|
||||||
@@ -41,9 +38,8 @@ func deployTestService(t *testing.T, ctx context.Context, cli *client.Client, na
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if uint(len(service.Containers)) != replicas {
|
assertServiceMatchesSpec(t, service, spec)
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, ctr := range service.Containers {
|
for _, ctr := range service.Containers {
|
||||||
if ctr.Container.State.Status != "running" {
|
if ctr.Container.State.Status != "running" {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package e2e
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1682,4 +1683,115 @@ func TestServiceLifecycle(t *testing.T) {
|
|||||||
assert.Equal(t, api.ServiceModeGlobal, svc.Mode)
|
assert.Equal(t, api.ServiceModeGlobal, svc.Mode)
|
||||||
assert.Len(t, svc.Containers, 3, "expected 1 container on each machine")
|
assert.Len(t, svc.Containers, 3, "expected 1 container on each machine")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("service logs", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
name := "test-service-logs"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := cli.RemoveService(ctx, name)
|
||||||
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
spec := api.ServiceSpec{
|
||||||
|
Name: name,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "busybox:1.37.0-musl",
|
||||||
|
Command: []string{
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"trap 'exit 0' TERM INT; " +
|
||||||
|
"echo \"Hello from $HOSTNAME\"; sleep 0.5;" +
|
||||||
|
"echo \"Hello stderr from $HOSTNAME\" >&2; " +
|
||||||
|
"while true; do sleep 1; done",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Replicas: 3,
|
||||||
|
}
|
||||||
|
deployment := cli.NewDeployment(spec, nil)
|
||||||
|
_, err := deployment.Run(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc, err := cli.InspectService(ctx, name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertServiceMatchesSpec(t, svc, spec)
|
||||||
|
|
||||||
|
machines := serviceMachines(svc)
|
||||||
|
assert.Len(t, machines.ToSlice(), 3, "should have containers on all 3 machines")
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second) // Wait for containers to produce the logs.
|
||||||
|
|
||||||
|
logsSvc, stream, err := cli.ServiceLogs(ctx, name, api.ServiceLogsOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertServiceMatchesSpec(t, logsSvc, spec)
|
||||||
|
|
||||||
|
collectLogs := func(stream <-chan api.ServiceLogEntry) []string {
|
||||||
|
var entries []string
|
||||||
|
timeout := time.After(5 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case entry, ok := <-stream:
|
||||||
|
if !ok {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, entry.Err)
|
||||||
|
if entry.Stream == api.LogStreamStdout || entry.Stream == api.LogStreamStderr {
|
||||||
|
entries = append(entries, string(entry.Message))
|
||||||
|
}
|
||||||
|
case <-timeout:
|
||||||
|
require.FailNow(t, "timed out waiting for logs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := collectLogs(stream)
|
||||||
|
require.Len(t, logs, 6, "should have 6 log entries (1 stdout and 1 stderr from each of 3 replicas)")
|
||||||
|
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
stdoutMsg := fmt.Sprintf("Hello from %s\n", ctr.Container.Name)
|
||||||
|
stderrMsg := fmt.Sprintf("Hello stderr from %s\n", ctr.Container.Name)
|
||||||
|
|
||||||
|
assert.Contains(t, logs, stdoutMsg)
|
||||||
|
assert.Contains(t, logs, stderrMsg)
|
||||||
|
|
||||||
|
outIdx := slices.Index(logs, stdoutMsg)
|
||||||
|
errIdx := slices.Index(logs, stderrMsg)
|
||||||
|
assert.Less(t, outIdx, errIdx, "stdout log should appear before stderr log")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test filter logs by machine ID.
|
||||||
|
machineID := c.Machines[0].ID
|
||||||
|
_, machineStream, err := cli.ServiceLogs(ctx, name, api.ServiceLogsOptions{
|
||||||
|
Machines: []string{machineID},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
machineLogs := collectLogs(machineStream)
|
||||||
|
require.Len(t, machineLogs, 2, "should have 2 log entries (1 stdout and 1 stderr) from the filtered machine")
|
||||||
|
|
||||||
|
ctrName := ""
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
if ctr.MachineID == machineID {
|
||||||
|
ctrName = ctr.Container.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, ctrName, "should have found container on the target machine")
|
||||||
|
|
||||||
|
stdoutMsg := fmt.Sprintf("Hello from %s\n", ctrName)
|
||||||
|
stderrMsg := fmt.Sprintf("Hello stderr from %s\n", ctrName)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{stdoutMsg, stderrMsg}, machineLogs)
|
||||||
|
|
||||||
|
// Test non-existent machine filter returns error.
|
||||||
|
_, _, err = cli.ServiceLogs(ctx, name, api.ServiceLogsOptions{
|
||||||
|
Machines: []string{"non-existent-machine"},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "machines not found")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ services:
|
|||||||
- linux/amd64
|
- linux/amd64
|
||||||
- linux/arm64
|
- linux/arm64
|
||||||
user: nobody
|
user: nobody
|
||||||
x-ports:
|
x-caddy: |
|
||||||
- uncloud.run:8000/https
|
uncloud.run {
|
||||||
|
redir /discord https://discord.gg/eR35KQJhPu
|
||||||
|
|
||||||
|
reverse_proxy {{upstreams 8000}} {
|
||||||
|
import common_proxy
|
||||||
|
}
|
||||||
|
log
|
||||||
|
}
|
||||||
scale: 2
|
scale: 2
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Talos [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/).
|
|||||||
|
|
||||||
### Managed DNS service (optional)
|
### Managed DNS service (optional)
|
||||||
|
|
||||||
Uncloud can provide **managed DNS records** like `<service-name>.<cluster-id>.cluster.uncloud.run` for your public
|
Uncloud can provide **managed DNS records** like `<service-name>.<cluster-id>.uncld.dev` for your public
|
||||||
services through free [Uncloud DNS](https://github.com/psviderski/uncloud-dns) service. You can deploy a service and
|
services through free [Uncloud DNS](https://github.com/psviderski/uncloud-dns) service. You can deploy a service and
|
||||||
instantly access it from anywhere with a proper DNS name and HTTPS without any manual DNS configuration. This makes
|
instantly access it from anywhere with a proper DNS name and HTTPS without any manual DNS configuration. This makes
|
||||||
self-hosting much more accessible and simplifies the process of adding your own domain later.
|
self-hosting much more accessible and simplifies the process of adding your own domain later.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ This command will:
|
|||||||
- Install the Uncloud daemon on your server
|
- Install the Uncloud daemon on your server
|
||||||
- Create a Docker network for Uncloud-managed containers
|
- Create a Docker network for Uncloud-managed containers
|
||||||
- Deploy [Caddy](https://caddyserver.com/) as your reverse proxy listening on host ports 80 and 443
|
- Deploy [Caddy](https://caddyserver.com/) as your reverse proxy listening on host ports 80 and 443
|
||||||
- Reserve a free `xxxxxx.cluster.uncloud.run` subdomain via the Uncloud managed DNS service and point it to your
|
- Reserve a free `xxxxxx.uncld.dev` subdomain via the Uncloud managed DNS service and point it to your
|
||||||
server's IP
|
server's IP
|
||||||
|
|
||||||
All in about a minute!
|
All in about a minute!
|
||||||
@@ -144,7 +144,7 @@ Cluster initialised with machine 'machine-dc3c' and saved as context 'default' i
|
|||||||
Current cluster context is now 'default'.
|
Current cluster context is now 'default'.
|
||||||
Waiting for the machine to be ready...
|
Waiting for the machine to be ready...
|
||||||
|
|
||||||
Reserved cluster domain: 7za6s7.cluster.uncloud.run
|
Reserved cluster domain: 7za6s7.uncld.dev
|
||||||
[+] Deploying service caddy 7/2
|
[+] Deploying service caddy 7/2
|
||||||
✔ Container caddy-d7uk on machine-dc3c Started 6.1s
|
✔ Container caddy-d7uk on machine-dc3c Started 6.1s
|
||||||
✔ Image caddy:2.10.0 on machine-dc3c Pulled 3.7s
|
✔ Image caddy:2.10.0 on machine-dc3c Pulled 3.7s
|
||||||
@@ -154,7 +154,7 @@ Updating cluster domain records in Uncloud DNS to point to machines running cadd
|
|||||||
✔ Machine machine-dc3c (157.180.72.195) Reachable 0.7s
|
✔ Machine machine-dc3c (157.180.72.195) Reachable 0.7s
|
||||||
|
|
||||||
DNS records updated to use only the internet-reachable machines running caddy service:
|
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||||
*.7za6s7.cluster.uncloud.run A → 157.180.72.195
|
*.7za6s7.uncld.dev A → 157.180.72.195
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -177,7 +177,7 @@ You'll see the progress of the deployment and the public URL where you can acces
|
|||||||
✔ Image excalidraw/excalidraw on machine-dc3c Pulled 4.7s
|
✔ Image excalidraw/excalidraw on machine-dc3c Pulled 4.7s
|
||||||
|
|
||||||
excalidraw endpoints:
|
excalidraw endpoints:
|
||||||
• https://excalidraw.7za6s7.cluster.uncloud.run → :80
|
• https://excalidraw.7za6s7.uncld.dev → :80
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verify your deployment
|
## Verify your deployment
|
||||||
@@ -209,7 +209,7 @@ uc ls
|
|||||||
```
|
```
|
||||||
NAME MODE REPLICAS ENDPOINTS
|
NAME MODE REPLICAS ENDPOINTS
|
||||||
caddy global 1
|
caddy global 1
|
||||||
excalidraw replicated 1 https://excalidraw.7za6s7.cluster.uncloud.run → :80
|
excalidraw replicated 1 https://excalidraw.7za6s7.uncld.dev → :80
|
||||||
```
|
```
|
||||||
|
|
||||||
You can see `caddy` service listed here. That's your reverse proxy, running as a regular Uncloud service.
|
You can see `caddy` service listed here. That's your reverse proxy, running as a regular Uncloud service.
|
||||||
@@ -266,15 +266,15 @@ We've successfully converted our deployment created with `uc run` to a Compose f
|
|||||||
|
|
||||||
## Use your own domain
|
## Use your own domain
|
||||||
|
|
||||||
Want to use your own domain, for example, `excalidraw.example.com` instead of `excalidraw.7za6s7.cluster.uncloud.run`?
|
Want to use your own domain, for example, `excalidraw.example.com` instead of `excalidraw.7za6s7.uncld.dev`?
|
||||||
|
|
||||||
Add a CNAME record `excalidraw.example.com` in your DNS provider (Cloudflare, Namecheap, etc.) pointing to
|
Add a CNAME record `excalidraw.example.com` in your DNS provider (Cloudflare, Namecheap, etc.) pointing to
|
||||||
`excalidraw.7za6s7.cluster.uncloud.run`. Alternatively, you can add an A record pointing to your server's IP.
|
`excalidraw.7za6s7.uncld.dev`. Alternatively, you can add an A record pointing to your server's IP.
|
||||||
|
|
||||||
:::info note
|
:::info note
|
||||||
|
|
||||||
These instructions set up your own domain **in addition to** the Uncloud managed DNS name
|
These instructions set up your own domain **in addition to** the Uncloud managed DNS name
|
||||||
`excalidraw.7za6s7.cluster.uncloud.run`.
|
`excalidraw.7za6s7.uncld.dev`.
|
||||||
|
|
||||||
If you want to avoid the managed service altogether, add `--no-dns` to your `uc machine init` command, and point an A
|
If you want to avoid the managed service altogether, add `--no-dns` to your `uc machine init` command, and point an A
|
||||||
DNS record to your server(s)'s IP(s).
|
DNS record to your server(s)'s IP(s).
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ This command looks for a Compose file in your current working directory and:
|
|||||||
5. **Deploys service containers** using the built images and latest configuration changes with zero-downtime rolling
|
5. **Deploys service containers** using the built images and latest configuration changes with zero-downtime rolling
|
||||||
updates
|
updates
|
||||||
|
|
||||||
See the [uc deploy](../../9-cli-reference/uc_deploy.md) reference for all available options.
|
See the [`uc deploy` ](../../9-cli-reference/uc_deploy.md) reference for all available options.
|
||||||
|
|
||||||
Watch `uc deploy` building and deploying a demo app (18 seconds):
|
Watch `uc deploy` building and deploying a demo app (18 seconds):
|
||||||
|
|
||||||
@@ -98,9 +98,10 @@ You can pass additional build arguments or override existing ones using the `--b
|
|||||||
uc deploy --build-arg BUILD_ENV=dev
|
uc deploy --build-arg BUILD_ENV=dev
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also use advanced features like build caches, secrets, and SSH access.
|
You can also use advanced features like build caches or SSH access.
|
||||||
See [Compose Build Specification](https://github.com/compose-spec/compose-spec/blob/main/build.md) for all supported
|
See [Compose Build Specification](https://github.com/compose-spec/compose-spec/blob/main/build.md) for all supported
|
||||||
attributes.
|
attributes. Note that build [secrets](https://github.com/compose-spec/compose-spec/blob/main/build.md#secrets) are not
|
||||||
|
supported though.
|
||||||
|
|
||||||
:::info note
|
:::info note
|
||||||
|
|
||||||
@@ -140,8 +141,8 @@ 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
|
`uc deploy` renders the image templates when it loads the Compose file and then uses the resulting names for the build
|
||||||
and deploy stages.
|
and deploy stages.
|
||||||
|
|
||||||
See the [Image tag template](../../8-compose-file-reference/2-image-tag-template.md) reference for all available template
|
See the [Image tag template](../../8-compose-file-reference/2-image-tag-template.md) reference for all available
|
||||||
variables and functions.
|
template variables and functions.
|
||||||
|
|
||||||
### Separate build and deploy steps
|
### Separate build and deploy steps
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ The following table shows the support status for main Compose features:
|
|||||||
| `replicas` | ✅ Supported | Number of container replicas |
|
| `replicas` | ✅ Supported | Number of container replicas |
|
||||||
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
|
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
|
||||||
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |
|
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |
|
||||||
|
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
|
||||||
|
| `update_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
|
||||||
| **Volumes** | | |
|
| **Volumes** | | |
|
||||||
| Named volumes | ✅ Supported | Docker volumes |
|
| Named volumes | ✅ Supported | Docker volumes |
|
||||||
| Bind mounts | ✅ Supported | Host path binding |
|
| Bind mounts | ✅ Supported | Host path binding |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Template syntax for tagging built images.
|
|||||||
When building service images as part of [`uc build`](../9-cli-reference/uc_build.md) or
|
When building service images as part of [`uc build`](../9-cli-reference/uc_build.md) or
|
||||||
[`uc deploy`](../9-cli-reference/uc_deploy.md) commands, Uncloud automatically generates image tags based on the current
|
[`uc deploy`](../9-cli-reference/uc_deploy.md) commands, Uncloud automatically generates image tags based on the current
|
||||||
Git repository state. You can customise the image name and tag format for the built images using the
|
Git repository state. You can customise the image name and tag format for the built images using the
|
||||||
[Go template](https://pkg.go.dev/text/template) syntax environment variables.
|
[Go template](https://pkg.go.dev/text/template) syntax and environment variables.
|
||||||
|
|
||||||
## Default template
|
## Default template
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ A CLI tool for managing Uncloud resources such as machines, services, and volume
|
|||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], or tcp://host:port
|
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||||
|
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||||
-h, --help help for uc
|
-h, --help help for uc
|
||||||
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
@@ -22,11 +23,13 @@ A CLI tool for managing Uncloud resources such as machines, services, and volume
|
|||||||
* [uc image](uc_image.md) - Manage images on machines in the cluster.
|
* [uc image](uc_image.md) - Manage images on machines in the cluster.
|
||||||
* [uc images](uc_images.md) - List images on machines in the cluster.
|
* [uc images](uc_images.md) - List images on machines in the cluster.
|
||||||
* [uc inspect](uc_inspect.md) - Display detailed information on a service.
|
* [uc inspect](uc_inspect.md) - Display detailed information on a service.
|
||||||
|
* [uc logs](uc_logs.md) - View service logs.
|
||||||
* [uc ls](uc_ls.md) - List services.
|
* [uc ls](uc_ls.md) - List services.
|
||||||
* [uc machine](uc_machine.md) - Manage machines in an Uncloud cluster.
|
* [uc machine](uc_machine.md) - Manage machines in the cluster.
|
||||||
|
* [uc ps](uc_ps.md) - List all service containers in the cluster
|
||||||
* [uc rm](uc_rm.md) - Remove one or more services.
|
* [uc rm](uc_rm.md) - Remove one or more services.
|
||||||
* [uc run](uc_run.md) - Run a service.
|
* [uc run](uc_run.md) - Run a service.
|
||||||
* [uc scale](uc_scale.md) - Scale a replicated service by changing the number of replicas.
|
* [uc scale](uc_scale.md) - Scale a replicated service by changing the number of replicas.
|
||||||
* [uc service](uc_service.md) - Manage services in an Uncloud cluster.
|
* [uc service](uc_service.md) - Manage services in the cluster.
|
||||||
* [uc volume](uc_volume.md) - Manage volumes in an Uncloud cluster.
|
* [uc volume](uc_volume.md) - Manage volumes in the cluster.
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ uc build [FLAGS] [SERVICE...] [flags]
|
|||||||
--build-arg stringArray Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.
|
--build-arg stringArray Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.
|
||||||
Can be specified multiple times. Format: --build-arg VAR=VALUE
|
Can be specified multiple times. Format: --build-arg VAR=VALUE
|
||||||
--check Check the build configuration for services without building them.
|
--check Check the build configuration for services without building them.
|
||||||
-c, --context string Name of the cluster context. (default is the current context)
|
|
||||||
--deps Also build services declared as dependencies of the selected services.
|
--deps Also build services declared as dependencies of the selected services.
|
||||||
-f, --file strings One or more Compose files to build. (default compose.yaml)
|
-f, --file strings One or more Compose files to build. (default compose.yaml)
|
||||||
-h, --help help for build
|
-h, --help help for build
|
||||||
@@ -59,7 +58,8 @@ uc build [FLAGS] [SERVICE...] [flags]
|
|||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], or tcp://host:port
|
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||||
|
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||||
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ Manage Caddy reverse proxy service.
|
|||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], or tcp://host:port
|
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||||
|
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||||
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ uc caddy config [flags]
|
|||||||
## Options
|
## Options
|
||||||
|
|
||||||
```
|
```
|
||||||
-c, --context string Name of the cluster context. (default is the current context)
|
|
||||||
-h, --help help for config
|
-h, --help help for config
|
||||||
-m, --machine string Name or ID of the machine to get the configuration from. (default is connected machine)
|
-m, --machine string Name or ID of the machine to get the configuration from. (default is connected machine)
|
||||||
--no-color Disable syntax highlighting for the output.
|
--no-color Disable syntax highlighting for the output.
|
||||||
@@ -23,7 +22,8 @@ uc caddy config [flags]
|
|||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], or tcp://host:port
|
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||||
|
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||||
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ uc caddy deploy [flags]
|
|||||||
|
|
||||||
```
|
```
|
||||||
--caddyfile string Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.
|
--caddyfile string Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.
|
||||||
-c, --context string Name of the cluster context to deploy to. (default is the current context)
|
|
||||||
-h, --help help for deploy
|
-h, --help help for deploy
|
||||||
--image string Caddy Docker image to deploy. (default caddy:LATEST_VERSION)
|
--image string Caddy Docker image to deploy. (default caddy:LATEST_VERSION)
|
||||||
-m, --machine strings Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated list. (default is all machines)
|
-m, --machine strings Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated list. (default is all machines)
|
||||||
@@ -25,7 +24,8 @@ uc caddy deploy [flags]
|
|||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], or tcp://host:port
|
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||||
|
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||||
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ uc ctx [flags]
|
|||||||
|
|
||||||
```
|
```
|
||||||
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
--connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]
|
||||||
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], or tcp://host:port
|
Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock
|
||||||
|
-c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT]
|
||||||
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
--uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml")
|
||||||
```
|
```
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes.
|
* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes.
|
||||||
|
* [uc ctx connection](uc_ctx_connection.md) - Choose a new default connection for the current context.
|
||||||
* [uc ctx ls](uc_ctx_ls.md) - List available cluster contexts.
|
* [uc ctx ls](uc_ctx_ls.md) - List available cluster contexts.
|
||||||
* [uc ctx use](uc_ctx_use.md) - Switch to a different cluster context.
|
* [uc ctx use](uc_ctx_use.md) - Switch to a different cluster context.
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user