From 80139ba2977d7bb4b439f484785ff6a2a313f4b5 Mon Sep 17 00:00:00 2001 From: Seokho Son Date: Fri, 20 Sep 2024 15:12:47 +0900 Subject: [PATCH] Add remote file transfer feature --- src/api/rest/docs/docs.go | 89 ++++++++ src/api/rest/docs/swagger.json | 89 ++++++++ src/api/rest/docs/swagger.yaml | 80 +++++++ src/api/rest/server/infra/remoteCommand.go | 78 +++++++ src/api/rest/server/server.go | 1 + src/core/infra/remoteCommand.go | 240 +++++++++++++++++++++ 6 files changed, 577 insertions(+) diff --git a/src/api/rest/docs/docs.go b/src/api/rest/docs/docs.go index 14c9b5c1e..d151bdba9 100644 --- a/src/api/rest/docs/docs.go +++ b/src/api/rest/docs/docs.go @@ -8535,6 +8535,95 @@ const docTemplate = `{ } } }, + "/ns/{nsId}/transferFile/mci/{mciId}": { + "post": { + "description": "Transfer a file to specified MCI to the specified path.\nThe file size should be less than 10MB.\nNot for gerneral file transfer but for specific purpose (small configuration files).", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "[MC-Infra] MCI Remote Command" + ], + "summary": "Transfer a file to specified MCI", + "operationId": "PostFileToMci", + "parameters": [ + { + "type": "string", + "default": "default", + "description": "Namespace ID", + "name": "nsId", + "in": "path", + "required": true + }, + { + "type": "string", + "default": "mci01", + "description": "MCI ID", + "name": "mciId", + "in": "path", + "required": true + }, + { + "type": "string", + "default": "g1", + "description": "subGroupId to apply the file transfer only for VMs in subGroup of MCI", + "name": "subGroupId", + "in": "query" + }, + { + "type": "string", + "default": "g1-1", + "description": "vmId to apply the file transfer only for a VM in MCI", + "name": "vmId", + "in": "query" + }, + { + "type": "string", + "default": "/home/cb-user/", + "description": "Target path where the file will be stored", + "name": "path", + "in": "formData", + "required": true + }, + { + "type": "file", + "description": "The file to be uploaded (Max 10MB)", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Custom request ID", + "name": "x-request-id", + "in": "header" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.MciSshCmdResult" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/model.SimpleMsg" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.SimpleMsg" + } + } + } + } + }, "/object": { "get": { "description": "Get value of an object", diff --git a/src/api/rest/docs/swagger.json b/src/api/rest/docs/swagger.json index 0e1159cf6..b90247ead 100644 --- a/src/api/rest/docs/swagger.json +++ b/src/api/rest/docs/swagger.json @@ -8529,6 +8529,95 @@ } } }, + "/ns/{nsId}/transferFile/mci/{mciId}": { + "post": { + "description": "Transfer a file to specified MCI to the specified path.\nThe file size should be less than 10MB.\nNot for gerneral file transfer but for specific purpose (small configuration files).", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "[MC-Infra] MCI Remote Command" + ], + "summary": "Transfer a file to specified MCI", + "operationId": "PostFileToMci", + "parameters": [ + { + "type": "string", + "default": "default", + "description": "Namespace ID", + "name": "nsId", + "in": "path", + "required": true + }, + { + "type": "string", + "default": "mci01", + "description": "MCI ID", + "name": "mciId", + "in": "path", + "required": true + }, + { + "type": "string", + "default": "g1", + "description": "subGroupId to apply the file transfer only for VMs in subGroup of MCI", + "name": "subGroupId", + "in": "query" + }, + { + "type": "string", + "default": "g1-1", + "description": "vmId to apply the file transfer only for a VM in MCI", + "name": "vmId", + "in": "query" + }, + { + "type": "string", + "default": "/home/cb-user/", + "description": "Target path where the file will be stored", + "name": "path", + "in": "formData", + "required": true + }, + { + "type": "file", + "description": "The file to be uploaded (Max 10MB)", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Custom request ID", + "name": "x-request-id", + "in": "header" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.MciSshCmdResult" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/model.SimpleMsg" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.SimpleMsg" + } + } + } + } + }, "/object": { "get": { "description": "Get value of an object", diff --git a/src/api/rest/docs/swagger.yaml b/src/api/rest/docs/swagger.yaml index c22658f04..e807924ef 100644 --- a/src/api/rest/docs/swagger.yaml +++ b/src/api/rest/docs/swagger.yaml @@ -6448,6 +6448,86 @@ paths: application/json: schema: $ref: '#/components/schemas/model.SimpleMsg' + /ns/{nsId}/transferFile/mci/{mciId}: + post: + tags: + - "[MC-Infra] MCI Remote Command" + summary: Transfer a file to specified MCI + description: |- + Transfer a file to specified MCI to the specified path. + The file size should be less than 10MB. + Not for gerneral file transfer but for specific purpose (small configuration files). + operationId: PostFileToMci + parameters: + - name: nsId + in: path + description: Namespace ID + required: true + schema: + type: string + default: default + - name: mciId + in: path + description: MCI ID + required: true + schema: + type: string + default: mci01 + - name: subGroupId + in: query + description: subGroupId to apply the file transfer only for VMs in subGroup + of MCI + schema: + type: string + default: g1 + - name: vmId + in: query + description: vmId to apply the file transfer only for a VM in MCI + schema: + type: string + default: g1-1 + - name: x-request-id + in: header + description: Custom request ID + schema: + type: string + requestBody: + content: + multipart/form-data: + schema: + required: + - file + - path + type: object + properties: + path: + type: string + description: Target path where the file will be stored + default: /home/cb-user/ + file: + type: string + description: The file to be uploaded (Max 10MB) + format: binary + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/model.MciSshCmdResult' + "400": + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/model.SimpleMsg' + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/model.SimpleMsg' /object: get: tags: diff --git a/src/api/rest/server/infra/remoteCommand.go b/src/api/rest/server/infra/remoteCommand.go index d7076132d..1ee335511 100644 --- a/src/api/rest/server/infra/remoteCommand.go +++ b/src/api/rest/server/infra/remoteCommand.go @@ -15,6 +15,8 @@ limitations under the License. package infra import ( + "fmt" + "io" "net/http" "github.com/cloud-barista/cb-tumblebug/src/core/common" @@ -76,6 +78,82 @@ func RestPostCmdMci(c echo.Context) error { } +// RestPostFileToMci godoc +// @ID PostFileToMci +// @Summary Transfer a file to specified MCI +// @Description Transfer a file to specified MCI to the specified path. +// @Description The file size should be less than 10MB. +// @Description Not for gerneral file transfer but for specific purpose (small configuration files). +// @Tags [MC-Infra] MCI Remote Command +// @Accept multipart/form-data +// @Produce json +// @Param nsId path string true "Namespace ID" default(default) +// @Param mciId path string true "MCI ID" default(mci01) +// @Param subGroupId query string false "subGroupId to apply the file transfer only for VMs in subGroup of MCI" default(g1) +// @Param vmId query string false "vmId to apply the file transfer only for a VM in MCI" default(g1-1) +// @Param path formData string true "Target path where the file will be stored" default(/home/cb-user/) +// @Param file formData file true "The file to be uploaded (Max 10MB)" +// @Param x-request-id header string false "Custom request ID" +// @Success 200 {object} model.MciSshCmdResult +// @Failure 400 {object} model.SimpleMsg "Invalid request" +// @Failure 500 {object} model.SimpleMsg "Internal Server Error" +// @Router /ns/{nsId}/transferFile/mci/{mciId} [post] +func RestPostFileToMci(c echo.Context) error { + reqID, idErr := common.StartRequestWithLog(c) + if idErr != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"message": idErr.Error()}) + } + nsId := c.Param("nsId") + mciId := c.Param("mciId") + subGroupId := c.QueryParam("subGroupId") + vmId := c.QueryParam("vmId") + targetPath := c.FormValue("path") + + if targetPath == "" { + err := fmt.Errorf("target path is required") + return common.EndRequestWithLog(c, reqID, err, nil) + } + + // Validate the file + file, err := c.FormFile("file") + if err != nil { + err = fmt.Errorf("failed to read the file %v", err) + return common.EndRequestWithLog(c, reqID, err, nil) + } + + // File size validation + fileSizeLimit := int64(10 * 1024 * 1024) // (10MB limit) + if file.Size > fileSizeLimit { + err := fmt.Errorf("file too large, max size is %v", fileSizeLimit) + return common.EndRequestWithLog(c, reqID, err, nil) + } + + // Open the file and read it into memory + src, err := file.Open() + if err != nil { + err = fmt.Errorf("failed to open the file %v", err) + return common.EndRequestWithLog(c, reqID, err, nil) + } + defer src.Close() + + // Read the file into memory + fileBytes, err := io.ReadAll(src) + if err != nil { + err = fmt.Errorf("failed to read the file %v", err) + return common.EndRequestWithLog(c, reqID, err, nil) + } + + // Call the TransferFileToMci function + result, err := infra.TransferFileToMci(nsId, mciId, subGroupId, vmId, fileBytes, file.Filename, targetPath) + if err != nil { + err = fmt.Errorf("failed to transfer file to mci %v", err) + return common.EndRequestWithLog(c, reqID, err, nil) + } + + // Return the result + return common.EndRequestWithLog(c, reqID, err, result) +} + // RestSetBastionNodes godoc // @ID SetBastionNodes // @Summary Set bastion nodes for a VM diff --git a/src/api/rest/server/server.go b/src/api/rest/server/server.go index d14545fc6..865cf961a 100644 --- a/src/api/rest/server/server.go +++ b/src/api/rest/server/server.go @@ -338,6 +338,7 @@ func RunServer(port string) { g.GET("/:nsId/control/mci/:mciId/vm/:vmId", rest_infra.RestGetControlMciVm) g.POST("/:nsId/cmd/mci/:mciId", rest_infra.RestPostCmdMci) + g.POST("/:nsId/transferFile/mci/:mciId", rest_infra.RestPostFileToMci) g.PUT("/:nsId/mci/:mciId/vm/:targetVmId/bastion/:bastionVmId", rest_infra.RestSetBastionNodes) g.DELETE("/:nsId/mci/:mciId/bastion/:bastionVmId", rest_infra.RestRemoveBastionNodes) g.GET("/:nsId/mci/:mciId/vm/:targetVmId/bastion", rest_infra.RestGetBastionNodes) diff --git a/src/core/infra/remoteCommand.go b/src/core/infra/remoteCommand.go index 4476227bb..edbc2c62e 100644 --- a/src/core/infra/remoteCommand.go +++ b/src/core/infra/remoteCommand.go @@ -559,6 +559,246 @@ func runSSH(bastionInfo model.SshInfo, targetInfo model.SshInfo, cmds []string) return stdoutMap, stderrMap, nil } +// TransferFileToMci is a function to transfer a file to all VMs in MCI by SSH through bastion hosts +func TransferFileToMci(nsId string, mciId string, subGroupId string, vmId string, fileData []byte, fileName string, targetPath string) ([]model.SshCmdResult, error) { + // Get the list of VMs in the MCI + vmList, err := ListVmId(nsId, mciId) + if err != nil { + return nil, err + } + // If a subGroupId is provided, filter the VM list by subGroup + if subGroupId != "" { + vmListInGroup, err := ListVmBySubGroup(nsId, mciId, subGroupId) + if err != nil { + return nil, err + } + vmList = vmListInGroup + } + // If a specific vmId is provided, limit the transfer to that VM only + if vmId != "" { + vmList = []string{vmId} + } + + // Create a wait group to sync goroutines + var wg sync.WaitGroup + var resultArray []model.SshCmdResult + var resultMutex sync.Mutex // To safely append to resultArray in concurrent goroutines + + // Iterate over the VM list to transfer the file + for _, vmId := range vmList { + wg.Add(1) + go func(vmId string) { + defer wg.Done() + log.Info().Msgf("Transferring file to VM: %s", vmId) + + _, targetVmIP, targetSshPort, _ := GetVmIp(nsId, mciId, vmId) + targetUserName, targetPrivateKey, _ := VerifySshUserName(nsId, mciId, vmId, targetVmIP, targetSshPort, "") + // error will be handled in the next step + + targetSshInfo := model.SshInfo{ + EndPoint: fmt.Sprintf("%s:%s", targetVmIP, targetSshPort), + UserName: targetUserName, + PrivateKey: []byte(targetPrivateKey), + } + + // Transfer file to the VM via bastion + err := transferFileToVmViaBastion(nsId, mciId, vmId, targetSshInfo, fileData, fileName, targetPath) + + // Create the result for this VM + result := model.SshCmdResult{ + MciId: mciId, + VmId: vmId, + VmIp: targetVmIP, + Command: map[int]string{0: fmt.Sprintf("scp %s to %s", fileName, targetPath)}, + Stdout: map[int]string{}, + Stderr: map[int]string{}, + } + + if err != nil { + result.Stderr[0] = fmt.Sprintf("Failed to transfer file: %v", err) + result.Err = fmt.Errorf("file transfer failed: %v", err) + log.Error().Err(err).Msgf("Failed to transfer file to VM: %s", vmId) + } else { + result.Stdout[0] = fmt.Sprintf("File transfer successful: %s%s", targetPath, fileName) + log.Info().Msgf("Successfully transferred file to VM: %s", vmId) + } + + // Safely append to resultArray + resultMutex.Lock() + resultArray = append(resultArray, result) + resultMutex.Unlock() + }(vmId) + } + wg.Wait() + + return resultArray, nil +} + +// transferFileToVmViaBastion is a function to transfer a file to a specific VM via Bastion Host +func transferFileToVmViaBastion(nsId string, mciId string, vmId string, targetSshInfo model.SshInfo, fileData []byte, fileName string, targetPath string) error { + + bastionNodes, err := GetBastionNodes(nsId, mciId, vmId) + if err != nil || len(bastionNodes) == 0 { + return fmt.Errorf("failed to get bastion nodes: %v", err) + } + + bastionNode := bastionNodes[0] + bastionIp, _, bastionSshPort, err := GetVmIp(nsId, bastionNode.MciId, bastionNode.VmId) + if err != nil { + return fmt.Errorf("failed to get bastion VM IP and SSH port: %v", err) + } + + bastionUserName, bastionPrivateKey, err := VerifySshUserName(nsId, bastionNode.MciId, bastionNode.VmId, bastionIp, bastionSshPort, "") + if err != nil { + return fmt.Errorf("failed to verify SSH username for bastion: %v", err) + } + + bastionSshInfo := model.SshInfo{ + EndPoint: fmt.Sprintf("%s:%s", bastionIp, bastionSshPort), + UserName: bastionUserName, + PrivateKey: []byte(bastionPrivateKey), + } + + err = runSCPWithBastion(bastionSshInfo, targetSshInfo, fileData, fileName, targetPath) + if err != nil { + return fmt.Errorf("failed to transfer file to VM via bastion: %v", err) + } + + log.Info().Msgf("File successfully transferred to VM %s via bastion", vmId) + return nil +} + +// runSCPWithBastion is func to send a file using SCP over SSH via a Bastion host +func runSCPWithBastion(bastionInfo model.SshInfo, targetInfo model.SshInfo, fileData []byte, fileName string, targetPath string) error { + log.Info().Msg("Setting up SCP connection via Bastion Host") + + // Parse the private key for the bastion host + bastionSigner, err := ssh.ParsePrivateKey(bastionInfo.PrivateKey) + if err != nil { + return fmt.Errorf("failed to parse bastion private key: %v", err) + } + + // Create an SSH client configuration for the bastion host + bastionConfig := &ssh.ClientConfig{ + User: bastionInfo.UserName, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(bastionSigner), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + // Parse the private key for the target host + targetSigner, err := ssh.ParsePrivateKey(targetInfo.PrivateKey) + if err != nil { + return fmt.Errorf("failed to parse target private key: %v", err) + } + + // Create an SSH client configuration for the target host + targetConfig := &ssh.ClientConfig{ + User: targetInfo.UserName, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(targetSigner), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + // Setup the bastion host connection + bastionClient, err := ssh.Dial("tcp", bastionInfo.EndPoint, bastionConfig) + if err != nil { + return fmt.Errorf("failed to dial bastion: %v", err) + } + defer bastionClient.Close() + + // Setup the actual SSH client through the bastion host + conn, err := bastionClient.Dial("tcp", targetInfo.EndPoint) + if err != nil { + return fmt.Errorf("failed to dial target via bastion: %v", err) + } + + ncc, chans, reqs, err := ssh.NewClientConn(conn, targetInfo.EndPoint, targetConfig) + if err != nil { + return fmt.Errorf("failed to create target SSH connection: %v", err) + } + client := ssh.NewClient(ncc, chans, reqs) + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("failed to create SSH session: %v", err) + } + defer session.Close() + + // Set up pipes for capturing stdout and stderr + stdout, err := session.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to set up stdout pipe: %v", err) + } + stderr, err := session.StderrPipe() + if err != nil { + return fmt.Errorf("failed to set up stderr pipe: %v", err) + } + + // Set up stdin pipe for SCP data transfer + stdin, err := session.StdinPipe() + if err != nil { + return fmt.Errorf("failed to set up stdin for SCP: %v", err) + } + + // Construct the SCP command and log it + targetFullPath := fmt.Sprintf("%s/%s", targetPath, fileName) + cmd := fmt.Sprintf("scp -t '%s'", targetFullPath) + log.Info().Msgf("Executing SCP command: %s", cmd) + + // Run the SCP command + if err := session.Start(cmd); err != nil { + stdin.Close() // Close stdin to signal error and exit early + return fmt.Errorf("failed to start SCP command: %v", err) + } + + // Send the file metadata (file size and permissions) + fileSize := len(fileData) + fmt.Fprintf(stdin, "C0644 %d %s\n", fileSize, fileName) + + // Log file data transfer initiation + log.Info().Msgf("Sending file data: %s (size: %d)", fileName, fileSize) + + // Write the file data to the remote server + _, err = stdin.Write(fileData) + if err != nil { + stdin.Close() // Close stdin to ensure resources are cleaned up + return fmt.Errorf("failed to write file data: %v", err) + } + + // End of file transmission (SCP protocol requires a 0-byte to signify EOF) + fmt.Fprint(stdin, "\x00") + + // Close stdin explicitly before waiting for the session to complete + stdin.Close() + + // Capture and log stdout and stderr + stdoutBuf := new(bytes.Buffer) + stderrBuf := new(bytes.Buffer) + + go io.Copy(stdoutBuf, stdout) + go io.Copy(stderrBuf, stderr) + + // Wait for SCP session to complete and check for errors + if err := session.Wait(); err != nil { + // Log stdout and stderr for better error diagnostics + log.Error().Msgf("SCP command failed with error: %v", err) + log.Error().Msgf("SCP stdout: %s", stdoutBuf.String()) + log.Error().Msgf("SCP stderr: %s", stderrBuf.String()) + + // Include stderr in the returned error + return fmt.Errorf("SCP command failed: %v, stderr: %s", err, stderrBuf.String()) + } + + // Log success message after file transfer is complete + log.Info().Msgf("File successfully transferred to %s via Bastion", targetFullPath) + + return nil +} + // SetBastionNodes func sets bastion nodes func SetBastionNodes(nsId string, mciId string, targetVmId string, bastionVmId string) (string, error) {