mirror of
https://github.com/luxfi/cli.git
synced 2026-10-30 17:01:20 +00:00
280 lines
9.3 KiB
Go
280 lines
9.3 KiB
Go
// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
package nodecmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/luxfi/cli/v2/pkg/node"
|
|
|
|
"github.com/luxfi/cli/v2/pkg/cobrautils"
|
|
"github.com/luxfi/cli/v2/pkg/models"
|
|
"github.com/luxfi/cli/v2/pkg/utils"
|
|
"github.com/luxfi/cli/v2/pkg/ux"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
isRecursive bool
|
|
withCompression bool
|
|
)
|
|
|
|
type ClusterOp int64
|
|
|
|
const (
|
|
noCluster ClusterOp = iota
|
|
srcCluster
|
|
dstCluster
|
|
)
|
|
|
|
func newSCPCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "scp SOURCE DEST",
|
|
Short: "(ALPHA Warning) Securely copy files to and from nodes",
|
|
Long: `(ALPHA Warning) This command is currently in experimental mode.
|
|
|
|
The node scp command securely copies files to and from nodes. Remote source or destionation can be specified using the following format:
|
|
[clusterName|nodeID|instanceID|IP]:/path/to/file. Regular expressions are supported for the source files like /tmp/*.txt.
|
|
File transfer to the nodes are parallelized. IF source or destination is cluster, the other should be a local file path.
|
|
If both destinations are remote, they must be nodes for the same cluster and not clusters themselves.
|
|
For example:
|
|
$ lux node scp [cluster1|node1]:/tmp/file.txt /tmp/file.txt
|
|
$ lux node scp /tmp/file.txt [cluster1|NodeID-XXXX]:/tmp/file.txt
|
|
$ lux node scp node1:/tmp/file.txt NodeID-XXXX:/tmp/file.txt
|
|
`,
|
|
Args: cobrautils.MinimumNArgs(2),
|
|
RunE: scpNode,
|
|
}
|
|
cmd.Flags().BoolVar(&isRecursive, "recursive", false, "copy directories recursively")
|
|
cmd.Flags().BoolVar(&withCompression, "compress", false, "use compression for ssh")
|
|
cmd.Flags().BoolVar(&includeMonitor, "with-monitor", false, "include monitoring node for scp cluster operations")
|
|
cmd.Flags().BoolVar(&includeLoadTest, "with-loadtest", false, "include loadtest node for scp cluster operations")
|
|
return cmd
|
|
}
|
|
|
|
func scpNode(_ *cobra.Command, args []string) error {
|
|
clustersConfig, err := app.GetClustersConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(clustersConfig.Clusters) == 0 {
|
|
ux.Logger.PrintToUser("There are no clusters defined.")
|
|
return nil
|
|
}
|
|
|
|
sourcePath, destPath := args[0], args[1]
|
|
sourceClusterNameOrNodeID, sourcePath := utils.SplitSCPPath(sourcePath)
|
|
destClusterNameOrNodeID, destPath := utils.SplitSCPPath(destPath)
|
|
|
|
// check if source and destination are both clusters
|
|
sourceClusterExists, err := node.CheckClusterExists(app, sourceClusterNameOrNodeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
destClusterExists, err := node.CheckClusterExists(app, destClusterNameOrNodeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sourceClusterExists && destClusterExists {
|
|
return fmt.Errorf("both source and destination cannot be clusters")
|
|
}
|
|
sourceClusterConfig := clustersConfig.Clusters[sourceClusterNameOrNodeID]
|
|
if sourceClusterExists && sourceClusterConfig.Local {
|
|
return notImplementedForLocal("scp")
|
|
}
|
|
|
|
destClusterConfig := clustersConfig.Clusters[destClusterNameOrNodeID]
|
|
if destClusterExists && destClusterConfig.Local {
|
|
return notImplementedForLocal("scp")
|
|
}
|
|
|
|
switch {
|
|
case sourceClusterExists:
|
|
// source is a cluster
|
|
clusterName := sourceClusterNameOrNodeID
|
|
clusterHosts, err := GetAllClusterHosts(clusterName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return scpHosts(srcCluster, clusterHosts, sourcePath, utils.CombineSCPPath(destClusterNameOrNodeID, destPath), clusterName, true)
|
|
case destClusterExists:
|
|
// destination is a cluster
|
|
clusterName := destClusterNameOrNodeID
|
|
clusterHosts, err := GetAllClusterHosts(clusterName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return scpHosts(dstCluster, clusterHosts, utils.CombineSCPPath(sourceClusterNameOrNodeID, sourcePath), destPath, clusterName, false)
|
|
default:
|
|
if sourceClusterNameOrNodeID == destClusterNameOrNodeID {
|
|
return fmt.Errorf("source and destination cannot be the same node")
|
|
}
|
|
// source is remote
|
|
srcPath := utils.CombineSCPPath(sourceClusterNameOrNodeID, sourcePath)
|
|
dstPath := utils.CombineSCPPath(destClusterNameOrNodeID, destPath)
|
|
ux.Logger.Info("scp src %s dst %s", srcPath, dstPath)
|
|
if sourceClusterNameOrNodeID != "" {
|
|
selectedHost, clusterName := getHostClusterPair(sourceClusterNameOrNodeID)
|
|
if selectedHost != nil && clusterName != "" {
|
|
return scpHosts(noCluster, []*models.Host{selectedHost}, srcPath, dstPath, clusterName, false)
|
|
}
|
|
} else if destClusterNameOrNodeID != "" {
|
|
selectedHost, clusterName := getHostClusterPair(destClusterNameOrNodeID)
|
|
if selectedHost != nil && clusterName != "" {
|
|
return scpHosts(noCluster, []*models.Host{selectedHost}, srcPath, dstPath, clusterName, false)
|
|
}
|
|
}
|
|
return fmt.Errorf("source or destination not found")
|
|
}
|
|
}
|
|
|
|
// scpHosts securely copies files to and from nodes.
|
|
func scpHosts(op ClusterOp, hosts []*models.Host, sourcePath, destPath string, clusterName string, separateNodeFolder bool) error {
|
|
wg := sync.WaitGroup{}
|
|
wgResults := models.NodeResults{}
|
|
spinSession := ux.NewUserSpinner()
|
|
for _, host := range hosts {
|
|
// prepare both source and destination for scp command
|
|
scpPrefix, err := prepareSCPTarget(op, host, clusterName, sourcePath, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scpSuffix, err := prepareSCPTarget(op, host, clusterName, destPath, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
prefixIP, prefixPath := utils.SplitSCPPath(scpPrefix)
|
|
suffixIP, suffixPath := utils.SplitSCPPath(scpSuffix)
|
|
switch op {
|
|
case srcCluster:
|
|
prefixIP = host.IP
|
|
// skip the same host
|
|
if suffixIP == host.IP {
|
|
continue
|
|
}
|
|
case dstCluster:
|
|
suffixIP = host.IP
|
|
// skip the same host
|
|
if prefixIP == host.IP {
|
|
continue
|
|
}
|
|
default:
|
|
// noCluster
|
|
}
|
|
if separateNodeFolder {
|
|
// add nodeID and clusterName to destination path if source is cluster, i.e. multiple nodes
|
|
suffixPath = fmt.Sprintf("%s/%s_%s/", suffixPath, clusterName, host.GetCloudID())
|
|
}
|
|
wg.Add(1)
|
|
go func(nodeResults *models.NodeResults, host *models.Host) {
|
|
defer wg.Done()
|
|
spinner := spinSession.SpinToUser(fmt.Sprintf("[%s] transferring file(s)", host.GetCloudID()))
|
|
// make sure that destination folder exists for generated path
|
|
if separateNodeFolder {
|
|
if err := os.MkdirAll(filepath.Dir(suffixPath), 0o755); err != nil {
|
|
ux.SpinFailWithError(spinner, "", err)
|
|
nodeResults.AddResult(host.NodeID, "", err)
|
|
return
|
|
}
|
|
}
|
|
scpCmd := ""
|
|
scpCmd, err = utils.GetSCPCommandString(
|
|
host.SSHPrivateKeyPath,
|
|
prefixIP,
|
|
prefixPath,
|
|
suffixIP,
|
|
suffixPath,
|
|
isRecursive,
|
|
withCompression)
|
|
if err != nil {
|
|
ux.SpinFailWithError(spinner, "", err)
|
|
nodeResults.AddResult(host.NodeID, "", err)
|
|
return
|
|
}
|
|
ux.Logger.Info("About to execute scp command: %s", scpCmd)
|
|
cmd := utils.Command(scpCmd)
|
|
|
|
if cmdOut, err := cmd.CombinedOutput(); err != nil {
|
|
ux.SpinFailWithError(spinner, string(cmdOut), err)
|
|
nodeResults.AddResult(host.NodeID, string(cmdOut), err)
|
|
} else {
|
|
ux.SpinComplete(spinner)
|
|
nodeResults.AddResult(host.NodeID, "", nil)
|
|
}
|
|
}(&wgResults, host)
|
|
}
|
|
wg.Wait()
|
|
spinSession.Stop()
|
|
if wgResults.HasErrors() {
|
|
return fmt.Errorf("failed to scp for node(s) %s", wgResults.GetErrorHostMap())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// prepareSCPTarget prepares the target for scp command
|
|
func prepareSCPTarget(op ClusterOp, host *models.Host, clusterName string, dest string, isSrc bool) (string, error) {
|
|
// valid clusterName - is already checked
|
|
if !strings.Contains(dest, ":") {
|
|
// destination is local, ready to go
|
|
return dest, nil
|
|
}
|
|
// destination is remote
|
|
node, path := utils.SplitSCPPath(dest)
|
|
if utils.IsValidIP(node) {
|
|
// destination is IP, ready to go
|
|
return dest, nil
|
|
}
|
|
// destination is cloudID or NodeID. clusterName is already checked and valid
|
|
clusterHosts, err := GetAllClusterHosts(clusterName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
selectedHost := utils.Filter(clusterHosts, func(h *models.Host) bool {
|
|
_, cloudHostID, _ := models.HostAnsibleIDToCloudID(h.NodeID)
|
|
hostNodeID, _ := getNodeID(app.GetNodeInstanceDirPath(cloudHostID))
|
|
return h.GetCloudID() == node || hostNodeID.String() == node || h.IP == node
|
|
})
|
|
switch {
|
|
case len(selectedHost) == 0:
|
|
return "", fmt.Errorf("node %s not found in cluster %s", node, clusterName)
|
|
case len(selectedHost) > 2:
|
|
return "", fmt.Errorf("more then 1 node found for %s in cluster %s", node, clusterName)
|
|
case (op == srcCluster && isSrc) || (op == dstCluster && !isSrc):
|
|
return fmt.Sprintf("%s:%s", host.IP, path), nil
|
|
default:
|
|
return fmt.Sprintf("%s:%s", selectedHost[0].IP, path), nil
|
|
}
|
|
}
|
|
|
|
// getHostClusterPair returns the host and cluster name for the given node or cloudID
|
|
func getHostClusterPair(nodeOrCloudIDOrIP string) (*models.Host, string) {
|
|
clustersConfig, err := app.GetClustersConfig()
|
|
if err != nil {
|
|
return nil, ""
|
|
}
|
|
for clusterName := range clustersConfig.Clusters {
|
|
clusterHosts, err := GetAllClusterHosts(clusterName)
|
|
if err != nil {
|
|
return nil, ""
|
|
}
|
|
selectedHost := utils.Filter(clusterHosts, func(h *models.Host) bool {
|
|
_, cloudHostID, _ := models.HostAnsibleIDToCloudID(h.NodeID)
|
|
hostNodeID, _ := getNodeID(app.GetNodeInstanceDirPath(cloudHostID))
|
|
return h.GetCloudID() == nodeOrCloudIDOrIP || hostNodeID.String() == nodeOrCloudIDOrIP || h.IP == nodeOrCloudIDOrIP
|
|
})
|
|
switch {
|
|
case len(selectedHost) == 0:
|
|
continue
|
|
case len(selectedHost) > 2:
|
|
return nil, ""
|
|
default:
|
|
return selectedHost[0], clusterName
|
|
}
|
|
}
|
|
return nil, ""
|
|
}
|