chore: update 'uc machine init/add' to wait for cluster readiness, confirm caddy deployment on added machine

This commit is contained in:
Pasha Sviderski
2025-12-23 19:47:26 +10:00
parent 9bd5ffcd2d
commit 5308c87651
2 changed files with 92 additions and 59 deletions
+72 -55
View File
@@ -8,7 +8,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/cenkalti/backoff/v4" "github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"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"
"github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli"
@@ -16,8 +17,6 @@ import (
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client" "github.com/psviderski/uncloud/pkg/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
) )
type addOptions struct { type addOptions struct {
@@ -131,79 +130,97 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
return nil return nil
} }
// Wait for the cluster to be initialised to be able to deploy the Caddy service. // Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service.
fmt.Println("Waiting for the machine to be ready...") err = spinner.New().
fmt.Println() Title(" Waiting for the machine to join the cluster...").
if err = waitClusterInitialised(ctx, machineClient); err != nil { Type(spinner.MiniDot).
return fmt.Errorf("wait for cluster to be initialised on machine: %w", err) Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
TitleStyle(lipgloss.NewStyle()).
ActionWithErr(func(ctx context.Context) error {
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
}).
Run()
if err != nil {
return fmt.Errorf("wait for machine to join the cluster: %w", err)
} }
fmt.Println("Machine joined the cluster.")
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
// that may cause a small downtime.
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines, // Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
// use the deployed image version. Otherwise, use the latest version. // use the deployed image version.
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have // NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
// issues accessing the Machine API of existing machines in the cluster. // issues accessing the Machine API of existing machines in the cluster.
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65. // See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
caddyImage := "" caddyImage := ""
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName) caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
if err != nil { if err != nil {
if !errors.Is(err, api.ErrNotFound) { if errors.Is(err, api.ErrNotFound) {
return fmt.Errorf("inspect caddy service: %w", err) // Caddy service is not deployed.
return nil
} }
} else { return fmt.Errorf("inspect caddy service: %w", err)
caddyImage = caddySvc.Containers[0].Container.Config.Image }
// Find the latest created container and use its image. caddyImage = caddySvc.Containers[0].Container.Config.Image
var latestCreated time.Time // Find the latest created container and use its image.
for _, c := range caddySvc.Containers[1:] { var latestCreated time.Time
created, err := time.Parse(time.RFC3339Nano, c.Container.Created) for _, c := range caddySvc.Containers[1:] {
if err != nil { created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
continue if err != nil {
} continue
if created.After(latestCreated) { }
latestCreated = created if created.After(latestCreated) {
caddyImage = c.Container.Config.Image latestCreated = created
} caddyImage = c.Container.Config.Image
} }
} }
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
// that may cause a small downtime.
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{}) d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
if err != nil { if err != nil {
return fmt.Errorf("create caddy deployment: %w", err) return fmt.Errorf("create caddy deployment: %w", err)
} }
err = progress.RunWithTitle(ctx, func(ctx context.Context) error { plan, err := d.Plan(ctx)
if _, err = d.Run(ctx); err != nil {
return fmt.Errorf("deploy caddy: %w", err)
}
return nil
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
if err != nil { if err != nil {
return err return fmt.Errorf("plan caddy deployment: %w", err)
}
fmt.Println()
if len(plan.Operations) == 0 {
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
} else {
// Initialise a machine and container name resolver to properly format the plan output.
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, caddySvc)
if err != nil {
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
}
fmt.Println("caddy deployment plan:")
fmt.Println(plan.Format(resolver))
fmt.Println()
if !opts.yes {
confirmed, err := cli.Confirm()
if err != nil {
return fmt.Errorf("confirm deployment: %w", err)
}
if !confirmed {
fmt.Println("Cancelled. No changes were made.")
return nil
}
}
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
if _, err = d.Run(ctx); err != nil {
return fmt.Errorf("deploy caddy: %w", err)
}
return nil
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode))
if err != nil {
return err
}
} }
fmt.Println() fmt.Println()
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut()) return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
} }
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
boff := backoff.WithContext(backoff.NewExponentialBackOff(
backoff.WithMaxInterval(1*time.Second),
backoff.WithMaxElapsedTime(5*time.Minute),
), ctx)
check := func() error {
_, err := client.ListMachines(ctx, nil)
if err == nil {
return nil
}
statusErr := status.Convert(err)
if statusErr.Code() == codes.FailedPrecondition {
return err
}
return backoff.Permanent(err)
}
return backoff.Retry(check, boff)
}
+20 -4
View File
@@ -5,7 +5,10 @@ import (
"fmt" "fmt"
"net/netip" "net/netip"
"strings" "strings"
"time"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"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"
"github.com/psviderski/uncloud/cmd/uncloud/dns" "github.com/psviderski/uncloud/cmd/uncloud/dns"
@@ -173,14 +176,27 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
} }
defer client.Close() defer client.Close()
// Since the cluster API needs a few moments to become ready after cluster initialisation,
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
err = spinner.New().
Title(" Waiting for the cluster to be ready...").
Type(spinner.MiniDot).
Style(lipgloss.NewStyle().Foreground(lipgloss.Color("3"))).
TitleStyle(lipgloss.NewStyle()).
ActionWithErr(func(ctx context.Context) error {
return client.WaitClusterReady(ctx, 1*time.Minute)
}).
Run()
if err != nil {
return fmt.Errorf("wait for cluster to be ready: %w", err)
}
fmt.Println("Cluster is ready.")
if opts.noCaddy && opts.noDNS { if opts.noCaddy && opts.noDNS {
return nil return nil
} }
// Deploy the Caddy service to the initialised machine.
// The creation of a deployment plan talks to cluster API. Since the API needs a few moments to become available
// after cluster initialisation, we keep the user informed during this wait.
fmt.Println("Waiting for the machine to be ready...")
fmt.Println() fmt.Println()
if !opts.noDNS { if !opts.noDNS {