mirror of
https://github.com/luxfi/cli.git
synced 2026-10-30 17:01:20 +00:00
327 lines
11 KiB
Go
327 lines
11 KiB
Go
// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
package nodecmd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
nodePkg "github.com/luxfi/cli/v2/pkg/node"
|
|
|
|
awsAPI "github.com/luxfi/cli/v2/pkg/cloud/aws"
|
|
gcpAPI "github.com/luxfi/cli/v2/pkg/cloud/gcp"
|
|
"github.com/luxfi/cli/v2/pkg/cobrautils"
|
|
"github.com/luxfi/cli/v2/pkg/constants"
|
|
"github.com/luxfi/cli/v2/pkg/utils"
|
|
"github.com/luxfi/cli/v2/pkg/ux"
|
|
"golang.org/x/exp/maps"
|
|
"golang.org/x/net/context"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
authorizeRemove bool
|
|
authorizeAll bool
|
|
destroyAll bool
|
|
)
|
|
|
|
func newDestroyCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "destroy [clusterName]",
|
|
Short: "(ALPHA Warning) Destroys all nodes in a cluster",
|
|
Long: `(ALPHA Warning) This command is currently in experimental mode.
|
|
|
|
The node destroy command terminates all running nodes in cloud server and deletes all storage disks.
|
|
|
|
If there is a static IP address attached, it will be released.`,
|
|
Args: cobrautils.MinimumNArgs(0),
|
|
RunE: destroyNodes,
|
|
}
|
|
cmd.Flags().BoolVar(&authorizeAccess, "authorize-access", false, "authorize CLI to release cloud resources")
|
|
cmd.Flags().BoolVar(&authorizeRemove, "authorize-remove", false, "authorize CLI to remove all local files related to cloud nodes")
|
|
cmd.Flags().BoolVarP(&authorizeAll, "authorize-all", "y", false, "authorize all CLI requests")
|
|
cmd.Flags().BoolVar(&destroyAll, "all", false, "destroy all existing clusters created by Lux CLI")
|
|
cmd.Flags().StringVar(&awsProfile, "aws-profile", constants.AWSDefaultCredential, "aws profile to use")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func removeNodeFromClustersConfig(clusterName string) error {
|
|
clustersConfig, err := app.GetClustersConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if clustersConfig.Clusters != nil {
|
|
delete(clustersConfig.Clusters, clusterName)
|
|
}
|
|
return app.WriteClustersConfigFile(&clustersConfig)
|
|
}
|
|
|
|
func removeDeletedNodeDirectory(clusterName string) error {
|
|
return os.RemoveAll(app.GetNodeInstanceDirPath(clusterName))
|
|
}
|
|
|
|
func removeClusterInventoryDir(clusterName string) error {
|
|
return os.RemoveAll(app.GetAnsibleInventoryDirPath(clusterName))
|
|
}
|
|
|
|
func getDeleteConfigConfirmation() error {
|
|
if authorizeRemove {
|
|
return nil
|
|
}
|
|
ux.Logger.PrintToUser("Please note that if your node(s) are validating a Subnet, destroying them could cause Subnet instability and it is irreversible")
|
|
confirm := "Running this command will delete all stored files associated with your cloud server. Do you want to proceed? " +
|
|
fmt.Sprintf("Stored files can be found at %s", app.GetNodesDir())
|
|
yes, err := app.Prompt.CaptureYesNo(confirm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !yes {
|
|
return errors.New("abort lux node destroy command")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func removeClustersConfigFiles(clusterName string) error {
|
|
if err := removeClusterInventoryDir(clusterName); err != nil {
|
|
return err
|
|
}
|
|
return removeNodeFromClustersConfig(clusterName)
|
|
}
|
|
|
|
func CallDestroyNode(clusterName string) error {
|
|
authorizeAll = true
|
|
return destroyNodes(nil, []string{clusterName})
|
|
}
|
|
|
|
// We need to get which cloud service is being used on a cluster
|
|
// getFirstAvailableNode gets first node in the cluster that still has its node_config.json
|
|
// This is because some nodes might have had their node_config.json file deleted as part of
|
|
// deletion process but if an error occurs during deletion process, the node might still exist
|
|
// as part of the cluster in cluster_config.json
|
|
// If all nodes in the cluster no longer have their node_config.json files, getFirstAvailableNode
|
|
// will return false in its second return value
|
|
func getFirstAvailableNode(nodesToStop []string) (string, bool) {
|
|
firstAvailableNode := nodesToStop[0]
|
|
noAvailableNodesFound := false
|
|
for index, node := range nodesToStop {
|
|
nodeConfigPath := app.GetNodeConfigPath(node)
|
|
if !utils.FileExists(nodeConfigPath) {
|
|
if index == len(nodesToStop)-1 {
|
|
noAvailableNodesFound = true
|
|
}
|
|
continue
|
|
}
|
|
firstAvailableNode = node
|
|
}
|
|
return firstAvailableNode, noAvailableNodesFound
|
|
}
|
|
|
|
func Cleanup() error {
|
|
clustersConfig, err := app.GetClustersConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
clusterNames := maps.Keys(clustersConfig.Clusters)
|
|
for _, clusterName := range clusterNames {
|
|
if err = CallDestroyNode(clusterName); err != nil {
|
|
// we only return error for invalid cloud credentials
|
|
// silence for other errors
|
|
// TODO: differentiate between AWS and GCP credentials
|
|
if strings.Contains(err.Error(), "invalid cloud credentials") {
|
|
return fmt.Errorf("invalid AWS credentials")
|
|
}
|
|
}
|
|
}
|
|
ux.Logger.PrintToUser("all existing instances created by Lux CLI successfully destroyed")
|
|
return nil
|
|
}
|
|
|
|
func destroyNodes(_ *cobra.Command, args []string) error {
|
|
if len(args) == 0 {
|
|
if !destroyAll {
|
|
return fmt.Errorf("to destroy all existing clusters created by Lux CLI, call lux node destroy --all. To destroy a specified cluster, call lux node destroy CLUSTERNAME")
|
|
}
|
|
return Cleanup()
|
|
}
|
|
clusterName := args[0]
|
|
if err := nodePkg.CheckCluster(app, clusterName); err != nil {
|
|
return err
|
|
}
|
|
clusterConfig, err := app.GetClusterConfig(clusterName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if clusterConfig.Local {
|
|
return notImplementedForLocal("destroy")
|
|
}
|
|
isExternalCluster, err := checkClusterExternal(clusterName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if authorizeAll {
|
|
authorizeAccess = true
|
|
authorizeRemove = true
|
|
}
|
|
if err := getDeleteConfigConfirmation(); err != nil {
|
|
return err
|
|
}
|
|
nodesToStop, err := nodePkg.GetClusterNodes(app, clusterName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
monitoringNode, err := getClusterMonitoringNode(clusterName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if monitoringNode != "" {
|
|
nodesToStop = append(nodesToStop, monitoringNode)
|
|
}
|
|
// stop all load test nodes if specified
|
|
ltHosts, err := getLoadTestInstancesInCluster(clusterName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, loadTestName := range ltHosts {
|
|
ltInstance, err := getExistingLoadTestInstance(clusterName, loadTestName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nodesToStop = append(nodesToStop, ltInstance)
|
|
}
|
|
nodeErrors := map[string]error{}
|
|
cloudSecurityGroupList, err := getCloudSecurityGroupList(nodesToStop)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
firstAvailableNodes, noAvailableNodesFound := getFirstAvailableNode(nodesToStop)
|
|
if noAvailableNodesFound {
|
|
return removeClustersConfigFiles(clusterName)
|
|
}
|
|
nodeToStopConfig, err := app.LoadClusterNodeConfig(firstAvailableNodes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// TODO: will need to change this logic if we decide to mix AWS and GCP instances in a cluster
|
|
filteredSGList := utils.Filter(cloudSecurityGroupList, func(sg regionSecurityGroup) bool { return sg.cloud == nodeToStopConfig.CloudService })
|
|
if len(filteredSGList) == 0 {
|
|
return fmt.Errorf("no endpoint found in the %s", nodeToStopConfig.CloudService)
|
|
}
|
|
var gcpCloud *gcpAPI.GcpCloud
|
|
ec2SvcMap := make(map[string]*awsAPI.AwsCloud)
|
|
// TODO: need implementation for GCP
|
|
if nodeToStopConfig.CloudService == constants.AWSCloudService {
|
|
for _, sg := range filteredSGList {
|
|
sgEc2Svc, err := awsAPI.NewAwsCloud(awsProfile, sg.region)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ec2SvcMap[sg.region] = sgEc2Svc
|
|
}
|
|
}
|
|
for _, node := range nodesToStop {
|
|
if !isExternalCluster {
|
|
// if we can't find node config path, that means node already deleted on console
|
|
// but we didn't get to delete the node from cluster config file
|
|
if !utils.FileExists(app.GetNodeConfigPath(node)) {
|
|
continue
|
|
}
|
|
nodeConfig, err := app.LoadClusterNodeConfig(node)
|
|
if err != nil {
|
|
nodeErrors[node] = err
|
|
ux.Logger.RedXToUser("Failed to destroy node %s due to %s", node, err.Error())
|
|
continue
|
|
}
|
|
if nodeConfig.CloudService == "" || nodeConfig.CloudService == constants.AWSCloudService {
|
|
if !(authorizeAccess || nodePkg.AuthorizedAccessFromSettings(app)) && (requestCloudAuth(constants.AWSCloudService) != nil) {
|
|
return fmt.Errorf("cloud access is required")
|
|
}
|
|
if err = ec2SvcMap[nodeConfig.Region].DestroyAWSNode(nodeConfig, clusterName); err != nil {
|
|
if isExpiredCredentialError(err) {
|
|
ux.Logger.PrintToUser("")
|
|
printExpiredCredentialsOutput(awsProfile)
|
|
return fmt.Errorf("invalid cloud credentials")
|
|
}
|
|
if !errors.Is(err, awsAPI.ErrNodeNotFoundToBeRunning) {
|
|
nodeErrors[node] = err
|
|
continue
|
|
}
|
|
ux.Logger.PrintToUser("node %s is already destroyed", nodeConfig.NodeID)
|
|
}
|
|
for _, sg := range filteredSGList {
|
|
if err = deleteHostSecurityGroupRule(ec2SvcMap[sg.region], nodeConfig.ElasticIP, sg.securityGroup); err != nil {
|
|
ux.Logger.RedXToUser("unable to delete IP address %s from security group %s in region %s due to %s, please delete it manually",
|
|
nodeConfig.ElasticIP, sg.securityGroup, sg.region, err.Error())
|
|
}
|
|
}
|
|
} else {
|
|
if !(authorizeAccess || nodePkg.AuthorizedAccessFromSettings(app)) && (requestCloudAuth(constants.GCPCloudService) != nil) {
|
|
return fmt.Errorf("cloud access is required")
|
|
}
|
|
if gcpCloud == nil {
|
|
gcpClient, projectName, _, err := getGCPCloudCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
gcpCloud, err = gcpAPI.NewGcpCloud(gcpClient, projectName, context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err = gcpCloud.DestroyGCPNode(nodeConfig, clusterName); err != nil {
|
|
if !errors.Is(err, gcpAPI.ErrNodeNotFoundToBeRunning) {
|
|
nodeErrors[node] = err
|
|
continue
|
|
}
|
|
ux.Logger.GreenCheckmarkToUser("node %s is already destroyed", nodeConfig.NodeID)
|
|
}
|
|
}
|
|
ux.Logger.GreenCheckmarkToUser("Node instance %s in cluster %s successfully destroyed!", nodeConfig.NodeID, clusterName)
|
|
}
|
|
if err := removeDeletedNodeDirectory(node); err != nil {
|
|
ux.Logger.RedXToUser("Failed to delete node config for node %s due to %s", node, err.Error())
|
|
return err
|
|
}
|
|
}
|
|
if len(nodeErrors) > 0 {
|
|
ux.Logger.PrintToUser("Failed nodes: ")
|
|
invalidCloudCredentials := false
|
|
for node, nodeErr := range nodeErrors {
|
|
if strings.Contains(nodeErr.Error(), constants.ErrReleasingGCPStaticIP) {
|
|
ux.Logger.RedXToUser("Node is destroyed, but failed to release static ip address for node %s due to %s", node, nodeErr)
|
|
} else {
|
|
if strings.Contains(nodeErr.Error(), "AuthFailure") {
|
|
invalidCloudCredentials = true
|
|
}
|
|
ux.Logger.RedXToUser("Failed to destroy node %s due to %s", node, nodeErr)
|
|
}
|
|
}
|
|
if invalidCloudCredentials {
|
|
return fmt.Errorf("failed to destroy node(s) due to invalid cloud credentials %s", maps.Keys(nodeErrors))
|
|
}
|
|
return fmt.Errorf("failed to destroy node(s) %s", maps.Keys(nodeErrors))
|
|
} else {
|
|
if isExternalCluster {
|
|
ux.Logger.GreenCheckmarkToUser("All nodes in EXTERNAL cluster %s are successfully removed!", clusterName)
|
|
} else {
|
|
ux.Logger.GreenCheckmarkToUser("All nodes in cluster %s are successfully destroyed!", clusterName)
|
|
}
|
|
}
|
|
|
|
return removeClustersConfigFiles(clusterName)
|
|
}
|
|
|
|
func getClusterMonitoringNode(clusterName string) (string, error) {
|
|
clustersConfig, err := app.GetClustersConfig()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, ok := clustersConfig.Clusters[clusterName]; !ok {
|
|
return "", fmt.Errorf("cluster %q does not exist", clusterName)
|
|
}
|
|
return clustersConfig.Clusters[clusterName].MonitoringInstance, nil
|
|
}
|