github.com/argoproj/argo-cd/v3@v3.2.1/util/cmp/stream.go (about)

     1  package cmp
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"crypto/sha256"
     7  	"encoding/hex"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"math"
    12  	"os"
    13  	"path/filepath"
    14  	"strings"
    15  
    16  	log "github.com/sirupsen/logrus"
    17  
    18  	pluginclient "github.com/argoproj/argo-cd/v3/cmpserver/apiclient"
    19  	"github.com/argoproj/argo-cd/v3/common"
    20  	"github.com/argoproj/argo-cd/v3/util/io/files"
    21  	"github.com/argoproj/argo-cd/v3/util/tgzstream"
    22  )
    23  
    24  // StreamSender defines the contract to send App files over stream
    25  type StreamSender interface {
    26  	Send(*pluginclient.AppStreamRequest) error
    27  }
    28  
    29  // StreamReceiver defines the contract for receiving Application's files
    30  // over gRPC stream
    31  type StreamReceiver interface {
    32  	Recv() (*pluginclient.AppStreamRequest, error)
    33  }
    34  
    35  // ReceiveRepoStream will receive the repository files and save them
    36  // in destDir. Will return the stream metadata if no error. Metadata
    37  // will be nil in case of errors.
    38  func ReceiveRepoStream(ctx context.Context, receiver StreamReceiver, destDir string, preserveFileMode bool) (*pluginclient.ManifestRequestMetadata, error) {
    39  	header, err := receiver.Recv()
    40  	if err != nil {
    41  		return nil, fmt.Errorf("error receiving stream header: %w", err)
    42  	}
    43  	if header == nil || header.GetMetadata() == nil {
    44  		return nil, errors.New("error getting stream metadata: metadata is nil")
    45  	}
    46  	metadata := header.GetMetadata()
    47  
    48  	tgzFile, err := receiveFile(ctx, receiver, metadata.GetChecksum(), destDir)
    49  	if err != nil {
    50  		return nil, fmt.Errorf("error receiving tgz file: %w", err)
    51  	}
    52  	err = files.Untgz(destDir, tgzFile, math.MaxInt64, preserveFileMode)
    53  	if err != nil {
    54  		return nil, fmt.Errorf("error decompressing tgz file: %w", err)
    55  	}
    56  	err = os.Remove(tgzFile.Name())
    57  	if err != nil {
    58  		log.Warnf("error removing the tgz file %q: %s", tgzFile.Name(), err)
    59  	}
    60  	return metadata, nil
    61  }
    62  
    63  // SenderOption defines the function type to by used by specific options
    64  type SenderOption func(*senderOption)
    65  
    66  type senderOption struct {
    67  	chunkSize   int
    68  	tarDoneChan chan<- bool
    69  }
    70  
    71  func newSenderOption(opts ...SenderOption) *senderOption {
    72  	so := &senderOption{
    73  		chunkSize: common.GetCMPChunkSize(),
    74  	}
    75  	for _, opt := range opts {
    76  		opt(so)
    77  	}
    78  	return so
    79  }
    80  
    81  func WithTarDoneChan(ch chan<- bool) SenderOption {
    82  	return func(opt *senderOption) {
    83  		opt.tarDoneChan = ch
    84  	}
    85  }
    86  
    87  // SendRepoStream will compress the files under the given rootPath and send
    88  // them using the plugin stream sender.
    89  func SendRepoStream(ctx context.Context, appPath, rootPath string, sender StreamSender, env []string, excludedGlobs []string, opts ...SenderOption) error {
    90  	opt := newSenderOption(opts...)
    91  
    92  	tgz, mr, err := GetCompressedRepoAndMetadata(rootPath, appPath, env, excludedGlobs, opt)
    93  	if err != nil {
    94  		return err
    95  	}
    96  	defer tgzstream.CloseAndDelete(tgz)
    97  	err = sender.Send(mr)
    98  	if err != nil {
    99  		return fmt.Errorf("error sending generate manifest metadata to cmp-server: %w", err)
   100  	}
   101  
   102  	// send the compressed file
   103  	err = sendFile(ctx, sender, tgz, opt)
   104  	if err != nil {
   105  		return fmt.Errorf("error sending tgz file to cmp-server: %w", err)
   106  	}
   107  	return nil
   108  }
   109  
   110  func GetCompressedRepoAndMetadata(rootPath string, appPath string, env []string, excludedGlobs []string, opt *senderOption) (*os.File, *pluginclient.AppStreamRequest, error) {
   111  	// compress all files in rootPath in tgz
   112  	tgz, filesWritten, checksum, err := tgzstream.CompressFiles(rootPath, nil, excludedGlobs)
   113  	if err != nil {
   114  		return nil, nil, fmt.Errorf("error compressing repo files: %w", err)
   115  	}
   116  	if filesWritten == 0 {
   117  		return nil, nil, fmt.Errorf("no files to send(%s)", rootPath)
   118  	}
   119  	if opt != nil && opt.tarDoneChan != nil {
   120  		opt.tarDoneChan <- true
   121  		close(opt.tarDoneChan)
   122  	}
   123  
   124  	fi, err := tgz.Stat()
   125  	if err != nil {
   126  		return nil, nil, fmt.Errorf("error getting tgz stat: %w", err)
   127  	}
   128  	appRelPath, err := files.RelativePath(appPath, rootPath)
   129  	if err != nil {
   130  		return nil, nil, fmt.Errorf("error building app relative path: %w", err)
   131  	}
   132  	// send metadata first
   133  	mr := appMetadataRequest(filepath.Base(appPath), appRelPath, env, checksum, fi.Size())
   134  	return tgz, mr, err
   135  }
   136  
   137  // sendFile will send the file over the gRPC stream using a
   138  // buffer.
   139  func sendFile(ctx context.Context, sender StreamSender, file *os.File, opt *senderOption) error {
   140  	reader := bufio.NewReader(file)
   141  	chunk := make([]byte, opt.chunkSize)
   142  	for {
   143  		if ctx != nil {
   144  			if err := ctx.Err(); err != nil {
   145  				return fmt.Errorf("client stream context error: %w", err)
   146  			}
   147  		}
   148  		n, err := reader.Read(chunk)
   149  		if n > 0 {
   150  			fr := AppFileRequest(chunk[:n])
   151  			if e := sender.Send(fr); e != nil {
   152  				return fmt.Errorf("error sending stream: %w", e)
   153  			}
   154  		}
   155  		if err != nil {
   156  			if err == io.EOF {
   157  				break
   158  			}
   159  			return fmt.Errorf("buffer reader error: %w", err)
   160  		}
   161  	}
   162  	return nil
   163  }
   164  
   165  // receiveFile will receive the file from the gRPC stream and save it in the dst folder.
   166  // Returns error if checksum doesn't match the one provided in the fileMetadata.
   167  // It is responsibility of the caller to close the returned file.
   168  func receiveFile(ctx context.Context, receiver StreamReceiver, checksum, dst string) (*os.File, error) {
   169  	hasher := sha256.New()
   170  	file, err := os.CreateTemp(dst, "")
   171  	if err != nil {
   172  		return nil, fmt.Errorf("error creating file: %w", err)
   173  	}
   174  	for {
   175  		if ctx != nil {
   176  			if err := ctx.Err(); err != nil {
   177  				return nil, fmt.Errorf("stream context error: %w", err)
   178  			}
   179  		}
   180  		req, err := receiver.Recv()
   181  		if err != nil {
   182  			if errors.Is(err, io.EOF) {
   183  				break
   184  			}
   185  			return nil, fmt.Errorf("stream Recv error: %w", err)
   186  		}
   187  		f := req.GetFile()
   188  		if f == nil {
   189  			return nil, errors.New("stream request file is nil")
   190  		}
   191  		_, err = file.Write(f.Chunk)
   192  		if err != nil {
   193  			return nil, fmt.Errorf("error writing file: %w", err)
   194  		}
   195  		_, err = hasher.Write(f.Chunk)
   196  		if err != nil {
   197  			return nil, fmt.Errorf("error writing hasher: %w", err)
   198  		}
   199  	}
   200  	if hex.EncodeToString(hasher.Sum(nil)) != checksum {
   201  		return nil, errors.New("file checksum validation error")
   202  	}
   203  
   204  	_, err = file.Seek(0, io.SeekStart)
   205  	if err != nil {
   206  		tgzstream.CloseAndDelete(file)
   207  		return nil, fmt.Errorf("seek error: %w", err)
   208  	}
   209  	return file, nil
   210  }
   211  
   212  // AppFileRequest build the file payload for the ManifestRequest
   213  func AppFileRequest(chunk []byte) *pluginclient.AppStreamRequest {
   214  	return &pluginclient.AppStreamRequest{
   215  		Request: &pluginclient.AppStreamRequest_File{
   216  			File: &pluginclient.File{
   217  				Chunk: chunk,
   218  			},
   219  		},
   220  	}
   221  }
   222  
   223  // appMetadataRequest build the metadata payload for the ManifestRequest
   224  func appMetadataRequest(appName, appRelPath string, env []string, checksum string, size int64) *pluginclient.AppStreamRequest {
   225  	return &pluginclient.AppStreamRequest{
   226  		Request: &pluginclient.AppStreamRequest_Metadata{
   227  			Metadata: &pluginclient.ManifestRequestMetadata{
   228  				AppName:    appName,
   229  				AppRelPath: appRelPath,
   230  				Checksum:   checksum,
   231  				Size_:      size,
   232  				Env:        toEnvEntry(env),
   233  			},
   234  		},
   235  	}
   236  }
   237  
   238  func toEnvEntry(envVars []string) []*pluginclient.EnvEntry {
   239  	envEntry := make([]*pluginclient.EnvEntry, 0)
   240  	for _, env := range envVars {
   241  		pair := strings.SplitN(env, "=", 2)
   242  		if len(pair) < 2 {
   243  			continue
   244  		}
   245  		envEntry = append(envEntry, &pluginclient.EnvEntry{Name: pair[0], Value: pair[1]})
   246  	}
   247  	return envEntry
   248  }