mirror of
https://github.com/luxfi/cli.git
synced 2026-10-30 17:01:20 +00:00
293 lines
9.7 KiB
Go
293 lines
9.7 KiB
Go
// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package nodecmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/luxfi/cli/pkg/ux"
|
|
"github.com/spf13/cobra"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/kubernetes"
|
|
)
|
|
|
|
var (
|
|
upgradeImage string
|
|
upgradeEvmVer string
|
|
stabilityWait time.Duration
|
|
healthTimeout time.Duration
|
|
dryRun bool
|
|
forceUpgrade bool
|
|
)
|
|
|
|
func newUpgradeCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "upgrade",
|
|
Short: "Rolling upgrade of luxd StatefulSet with zero downtime",
|
|
Long: `Performs a partition-based rolling upgrade of the luxd StatefulSet.
|
|
|
|
Upgrades one pod at a time (highest ordinal first), waiting for each pod
|
|
to become ready and stable before proceeding. This ensures C-chain RPC
|
|
clients experience no downtime since a quorum of validators remains
|
|
available throughout the upgrade.
|
|
|
|
PROCESS:
|
|
1. Validates the new image exists and differs from current
|
|
2. Updates the StatefulSet pod template with new image
|
|
3. Sets partition = replicas (no pods restart yet)
|
|
4. Lowers partition one at a time (pod N-1, N-2, ... 0)
|
|
5. After each pod restart: waits for readiness + stability period
|
|
6. If any pod fails health check, stops and prints rollback command
|
|
|
|
EXAMPLES:
|
|
lux node upgrade --mainnet --image ghcr.io/luxfi/node:v1.23.5
|
|
lux node upgrade --testnet --image ghcr.io/luxfi/node:v1.23.5 --stability-wait 60s
|
|
lux node upgrade --devnet --image ghcr.io/luxfi/node:v1.23.5 --dry-run`,
|
|
RunE: runUpgrade,
|
|
}
|
|
|
|
cmd.Flags().StringVar(&upgradeImage, "image", "", "new container image (required)")
|
|
cmd.Flags().StringVar(&upgradeEvmVer, "evm-version", "", "EVM plugin version to update in init container")
|
|
cmd.Flags().DurationVar(&stabilityWait, "stability-wait", 30*time.Second, "wait time after pod ready before proceeding")
|
|
cmd.Flags().DurationVar(&healthTimeout, "health-timeout", 5*time.Minute, "max time to wait for a pod to become ready")
|
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would happen without making changes")
|
|
cmd.Flags().BoolVar(&forceUpgrade, "force", false, "proceed even if image is the same")
|
|
|
|
_ = cmd.MarkFlagRequired("image")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func runUpgrade(_ *cobra.Command, _ []string) error {
|
|
namespace, err := resolveNamespace()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client, err := newK8sClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Get current StatefulSet
|
|
sts, err := client.AppsV1().StatefulSets(namespace).Get(ctx, statefulSetName, metav1.GetOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get StatefulSet %s in %s: %w", statefulSetName, namespace, err)
|
|
}
|
|
|
|
replicas := int32(1)
|
|
if sts.Spec.Replicas != nil {
|
|
replicas = *sts.Spec.Replicas
|
|
}
|
|
|
|
// Find current image
|
|
currentImage := ""
|
|
for _, c := range sts.Spec.Template.Spec.Containers {
|
|
if c.Name == containerName {
|
|
currentImage = c.Image
|
|
break
|
|
}
|
|
}
|
|
|
|
ux.Logger.PrintToUser("Upgrade plan:")
|
|
ux.Logger.PrintToUser(" Namespace: %s", namespace)
|
|
ux.Logger.PrintToUser(" StatefulSet: %s", statefulSetName)
|
|
ux.Logger.PrintToUser(" Replicas: %d", replicas)
|
|
ux.Logger.PrintToUser(" Current image: %s", currentImage)
|
|
ux.Logger.PrintToUser(" Target image: %s", upgradeImage)
|
|
ux.Logger.PrintToUser(" Stability: %s per pod", stabilityWait)
|
|
|
|
if currentImage == upgradeImage && !forceUpgrade {
|
|
ux.Logger.PrintToUser("\nImage is already %s — nothing to do (use --force to override)", upgradeImage)
|
|
return nil
|
|
}
|
|
|
|
if dryRun {
|
|
ux.Logger.PrintToUser("\n[dry-run] Would upgrade %d pods one at a time", replicas)
|
|
for i := replicas - 1; i >= 0; i-- {
|
|
ux.Logger.PrintToUser(" [dry-run] Upgrade %s-%d → wait ready → %s stability", statefulSetName, i, stabilityWait)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
ux.Logger.PrintToUser("\nStarting partition-based rolling upgrade...")
|
|
|
|
// Step 1: Set partition = replicas (freeze all pods)
|
|
if err := setPartition(ctx, client, namespace, replicas); err != nil {
|
|
return fmt.Errorf("failed to set partition: %w", err)
|
|
}
|
|
|
|
// Step 2: Update the pod template image
|
|
if err := patchImage(ctx, client, namespace, upgradeImage); err != nil {
|
|
return fmt.Errorf("failed to patch image: %w", err)
|
|
}
|
|
|
|
// Step 3: Update EVM plugin version in init container if specified
|
|
if upgradeEvmVer != "" {
|
|
if err := patchEvmInitContainer(ctx, client, namespace, upgradeEvmVer); err != nil {
|
|
ux.Logger.PrintToUser("Warning: failed to update EVM init container: %v", err)
|
|
}
|
|
}
|
|
|
|
// Step 4: Roll pods one at a time, highest ordinal first
|
|
for i := replicas - 1; i >= 0; i-- {
|
|
podName := fmt.Sprintf("%s-%d", statefulSetName, i)
|
|
ux.Logger.PrintToUser("\n[%d/%d] Upgrading %s...", replicas-i, replicas, podName)
|
|
|
|
// Lower partition to allow this pod to update
|
|
if err := setPartition(ctx, client, namespace, i); err != nil {
|
|
return fmt.Errorf("failed to lower partition to %d: %w", i, err)
|
|
}
|
|
|
|
// Wait for the pod to terminate and come back ready
|
|
ux.Logger.PrintToUser(" Waiting for %s to become ready (timeout %s)...", podName, healthTimeout)
|
|
if err := waitForPodReady(ctx, client, namespace, podName, healthTimeout); err != nil {
|
|
ux.Logger.PrintToUser(" FAILED: %s did not become ready: %v", podName, err)
|
|
ux.Logger.PrintToUser("\n To rollback: lux node rollback --%s", networkFlag(namespace))
|
|
return fmt.Errorf("upgrade halted at %s: %w", podName, err)
|
|
}
|
|
|
|
// Verify new image is actually running
|
|
actualImage, err := podImage(ctx, client, namespace, podName)
|
|
if err != nil {
|
|
ux.Logger.PrintToUser(" Warning: could not verify image: %v", err)
|
|
} else {
|
|
ux.Logger.PrintToUser(" Image: %s", actualImage)
|
|
}
|
|
|
|
// Stability wait
|
|
ux.Logger.PrintToUser(" Stability wait %s...", stabilityWait)
|
|
time.Sleep(stabilityWait)
|
|
|
|
// Re-check readiness after stability wait
|
|
ready, err := podReady(ctx, client, namespace, podName)
|
|
if err != nil || !ready {
|
|
ux.Logger.PrintToUser(" FAILED: %s lost readiness during stability wait", podName)
|
|
ux.Logger.PrintToUser("\n To rollback: lux node rollback --%s", networkFlag(namespace))
|
|
return fmt.Errorf("upgrade halted: %s unstable after %s", podName, stabilityWait)
|
|
}
|
|
|
|
ux.Logger.PrintToUser(" %s ready and stable", podName)
|
|
}
|
|
|
|
ux.Logger.PrintToUser("\nUpgrade complete. All %d pods running %s", replicas, upgradeImage)
|
|
return nil
|
|
}
|
|
|
|
// setPartition patches the StatefulSet updateStrategy partition value.
|
|
func setPartition(ctx context.Context, client *kubernetes.Clientset, namespace string, partition int32) error {
|
|
patch := map[string]interface{}{
|
|
"spec": map[string]interface{}{
|
|
"updateStrategy": map[string]interface{}{
|
|
"type": "RollingUpdate",
|
|
"rollingUpdate": map[string]interface{}{
|
|
"partition": partition,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
data, err := json.Marshal(patch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = client.AppsV1().StatefulSets(namespace).Patch(
|
|
ctx, statefulSetName, types.StrategicMergePatchType, data, metav1.PatchOptions{},
|
|
)
|
|
return err
|
|
}
|
|
|
|
// patchImage updates the luxd container image in the StatefulSet pod template.
|
|
func patchImage(ctx context.Context, client *kubernetes.Clientset, namespace, image string) error {
|
|
sts, err := client.AppsV1().StatefulSets(namespace).Get(ctx, statefulSetName, metav1.GetOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for i, c := range sts.Spec.Template.Spec.Containers {
|
|
if c.Name == containerName {
|
|
sts.Spec.Template.Spec.Containers[i].Image = image
|
|
break
|
|
}
|
|
}
|
|
|
|
// Ensure RollingUpdate strategy with high partition (set separately)
|
|
sts.Spec.UpdateStrategy = appsv1.StatefulSetUpdateStrategy{
|
|
Type: appsv1.RollingUpdateStatefulSetStrategyType,
|
|
RollingUpdate: &appsv1.RollingUpdateStatefulSetStrategy{
|
|
Partition: sts.Spec.Replicas, // freeze — will be lowered per-pod
|
|
},
|
|
}
|
|
|
|
_, err = client.AppsV1().StatefulSets(namespace).Update(ctx, sts, metav1.UpdateOptions{})
|
|
return err
|
|
}
|
|
|
|
// patchEvmInitContainer updates the EVM plugin download URL in the init container.
|
|
func patchEvmInitContainer(ctx context.Context, client *kubernetes.Clientset, namespace, evmVersion string) error {
|
|
sts, err := client.AppsV1().StatefulSets(namespace).Get(ctx, statefulSetName, metav1.GetOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pluginURL := fmt.Sprintf("https://github.com/luxfi/evm/releases/download/%s/evm-plugin-linux-amd64", evmVersion)
|
|
for i, ic := range sts.Spec.Template.Spec.InitContainers {
|
|
if ic.Name == "init-plugins" {
|
|
sts.Spec.Template.Spec.InitContainers[i].Args = []string{
|
|
"mkdir -p /data/plugins /data/staking && " +
|
|
fmt.Sprintf("curl -sL %s -o /data/plugins/mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6 && ", pluginURL) +
|
|
"chmod +x /data/plugins/* && " +
|
|
"chown -R 1000:1000 /data && " +
|
|
"echo 'Plugins installed successfully'",
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
_, err = client.AppsV1().StatefulSets(namespace).Update(ctx, sts, metav1.UpdateOptions{})
|
|
return err
|
|
}
|
|
|
|
// waitForPodReady polls until the named pod is Ready or timeout.
|
|
func waitForPodReady(ctx context.Context, client *kubernetes.Clientset, namespace, podName string, timeout time.Duration) error {
|
|
deadline := time.After(timeout)
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-deadline:
|
|
return fmt.Errorf("timeout after %s", timeout)
|
|
case <-ticker.C:
|
|
ready, err := podReady(ctx, client, namespace, podName)
|
|
if err != nil {
|
|
continue // pod may be terminating
|
|
}
|
|
if ready {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// networkFlag returns the flag name for a namespace (for error messages).
|
|
func networkFlag(namespace string) string {
|
|
switch namespace {
|
|
case "lux-mainnet":
|
|
return "mainnet"
|
|
case "lux-testnet":
|
|
return "testnet"
|
|
case "lux-devnet":
|
|
return "devnet"
|
|
default:
|
|
return fmt.Sprintf("namespace %s", namespace)
|
|
}
|
|
}
|