github.com/release-engineering/exodus-rsync@v1.11.2/internal/cmd/exodus.go (about)

     1  package cmd
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"fmt"
     7  	"os"
     8  	"path"
     9  	"path/filepath"
    10  	"strings"
    11  
    12  	"github.com/gabriel-vasile/mimetype"
    13  	"github.com/release-engineering/exodus-rsync/internal/args"
    14  	"github.com/release-engineering/exodus-rsync/internal/conf"
    15  	"github.com/release-engineering/exodus-rsync/internal/gw"
    16  	"github.com/release-engineering/exodus-rsync/internal/log"
    17  	"github.com/release-engineering/exodus-rsync/internal/walk"
    18  )
    19  
    20  func cleanDestTree(destTree string, strip string) string {
    21  	// If the configured strip string contains ":", any characters following the ":"
    22  	// must be stripped from the destination path.
    23  	//
    24  	// For example, if exodus-rsync is invoked with
    25  	// "exodus-rsync ./src/ otherhost:/foo/bar/baz/my/dest",
    26  	// args.Config.DestPath("otherhost:/foo/bar/baz/my/dest") returns
    27  	// "/foo/bar/baz/my/dest", and thus destTree is "/foo/bar/baz/my/dest".
    28  	// If the configuration contains "strip: otherhost:/foo", an additional "/foo"
    29  	// must be removed from the destination path ("/foo/bar/baz/my/dest"), which
    30  	// will publish to "/bar/baz/my/dest".
    31  	if strings.Contains(strip, ":") {
    32  		stripPrefix := strings.SplitN(strip, ":", 2)[1]
    33  		destTree = strings.TrimPrefix(destTree, stripPrefix)
    34  	}
    35  	return destTree
    36  }
    37  
    38  func getRelPath(srcPath string, srcTree string) string {
    39  	cleanSrcPath := path.Clean(srcPath)
    40  	cleanSrcTree := path.Clean(srcTree)
    41  	relPath := strings.TrimPrefix(cleanSrcPath, cleanSrcTree+"/")
    42  	return relPath
    43  }
    44  
    45  func webURI(srcPath string, srcTree string, destTree string, srcIsDir bool) string {
    46  	relPath := getRelPath(srcPath, srcTree+"/")
    47  
    48  	// Presence of trailing slash changes the behavior when assembling
    49  	// destination paths, see "man rsync" and search for "trailing".
    50  	if srcTree != "." && !strings.HasSuffix(srcTree, "/") {
    51  		srcBase := filepath.Base(srcTree)
    52  		if srcIsDir {
    53  			return path.Join(destTree, srcBase, relPath)
    54  		}
    55  		return destTree
    56  	}
    57  
    58  	return path.Join(destTree, relPath)
    59  }
    60  
    61  func commitMode(cfg conf.Config, args args.Config) (bool, string) {
    62  	// Calculates effective commit mode for current run, given arguments and config.
    63  	//
    64  	// Returns:
    65  	//   bool:   true if commit should happen at all
    66  	//   string: the commit_mode argument which should be passed to exodus-gw
    67  	//           (can be empty if no argument should be passed)
    68  	mode := cfg.GwCommit()
    69  	if mode == "" || mode == "auto" {
    70  		// 'auto' means commit with server-default mode if and only if we
    71  		// created the publish object during this run, which we did if no
    72  		// publish ID was passed in args.
    73  		return args.Publish == "", ""
    74  	}
    75  	if mode == "none" {
    76  		// 'none' means don't ever commit
    77  		return false, ""
    78  	}
    79  	// Anything else means commit, using the given value as the commit mode
    80  	return true, mode
    81  }
    82  
    83  func exodusMain(ctx context.Context, cfg conf.Config, args args.Config) int {
    84  	logger := log.FromContext(ctx)
    85  
    86  	clientCtor := ext.gw.NewClient
    87  	if args.DryRun {
    88  		clientCtor = ext.gw.NewDryRunClient
    89  	}
    90  	gwClient, err := clientCtor(ctx, cfg)
    91  	if err != nil {
    92  		logger.F("error", err).Error("can't initialize exodus-gw client")
    93  		return 101
    94  	}
    95  
    96  	var (
    97  		onlyThese []string
    98  		items     []walk.SyncItem
    99  	)
   100  
   101  	if args.FilesFrom != "" {
   102  		args.Relative = true
   103  
   104  		// When using --files-from, we don't want to recreate the source directory.
   105  		// Ensure the source path ends with a slash (/), indicating we only want it's contents.
   106  		if !strings.HasSuffix(args.Src, "/") {
   107  			args.Src += "/"
   108  		}
   109  
   110  		f, err := os.Open(args.FilesFrom)
   111  		if err != nil {
   112  			logger.F("src", args.Src, "error", err).Error("can't read --files-from file")
   113  			return 73
   114  		}
   115  		defer f.Close()
   116  
   117  		scanner := bufio.NewScanner(f)
   118  		for scanner.Scan() {
   119  			path := filepath.Join(args.Src, strings.TrimSpace(scanner.Text()))
   120  			onlyThese = append(onlyThese, path)
   121  		}
   122  	}
   123  
   124  	fileStat, err := os.Stat(args.Src)
   125  	if err != nil {
   126  		logger.F("error", err).Error("can't stat file")
   127  		return 73
   128  	}
   129  	srcIsDir := fileStat.IsDir()
   130  
   131  	logger.Info("Walking directory tree")
   132  	err = walk.Walk(ctx, args, onlyThese, func(item walk.SyncItem) error {
   133  		if args.IgnoreExisting {
   134  			// This argument is not (properly) supported, so bail out.
   135  			//
   136  			// We only check the argument here (after we've found an item) because we want
   137  			// the argument to be accepted if we're running over a directory tree with no
   138  			// files.
   139  			//
   140  			// The story with this is that some tools use an approach somewhat like this
   141  			// to implement a "remote mkdir":
   142  			//
   143  			//   mkdir empty
   144  			//   rsync --ignore-existing empty host:/dest/some/dir/which/should/be/created
   145  			//
   146  			// Since directories don't actually exist in exodus and there is no need to
   147  			// create a directory before writing to a particular path, this should be a
   148  			// no-op which successfully does nothing.  But any *other* attempted usage of
   149  			// --ignore-existing would be dangerous to ignore, as we can't actually deliver
   150  			// the requested semantics, so make it an error.
   151  			return fmt.Errorf("--ignore-existing is not supported")
   152  		}
   153  		items = append(items, item)
   154  		return nil
   155  	})
   156  	if err != nil {
   157  		logger.F("src", args.Src, "error", err).Error("can't read files for sync")
   158  		return 73
   159  	}
   160  
   161  	var publish gw.Publish
   162  
   163  	logger.F("items", len(items)).Info("Preparing to publish items")
   164  
   165  	if args.Publish == "" {
   166  		// No publish provided, then create a new one.
   167  		publish, err = gwClient.NewPublish(ctx)
   168  		if err != nil {
   169  			logger.F("error", err).Error("can't create publish")
   170  			return 62
   171  		}
   172  		logger.F("publish", publish.ID()).Info("Created publish")
   173  	} else {
   174  		publish, err = gwClient.GetPublish(ctx, args.Publish)
   175  		if err != nil {
   176  			logger.F("error", err).Error("can't join publish")
   177  			return 67
   178  		}
   179  		logger.F("publish", publish.ID()).Info("Joining publish")
   180  	}
   181  
   182  	logger.F("items", len(items)).Info("Preparing to upload items")
   183  
   184  	uploadCount := 0
   185  	existingCount := 0
   186  	duplicateCount := 0
   187  
   188  	err = gwClient.EnsureUploaded(ctx, items,
   189  		func(uploadedItem walk.SyncItem) error {
   190  			uploadCount++
   191  			return nil
   192  		},
   193  		func(existingItem walk.SyncItem) error {
   194  			existingCount++
   195  			return nil
   196  		},
   197  		func(duplicateItem walk.SyncItem) error {
   198  			duplicateCount++
   199  			return nil
   200  		},
   201  	)
   202  
   203  	if err != nil {
   204  		logger.F("error", err).Error("can't upload files")
   205  		return 25
   206  	}
   207  
   208  	logger.F("uploaded", uploadCount, "existing", existingCount, "duplicate", duplicateCount).Info("Completed uploads")
   209  
   210  	publishItems := []gw.ItemInput{}
   211  
   212  	strip := cfg.Strip()
   213  	destTree := cleanDestTree(args.DestPath(), strip)
   214  
   215  	for _, item := range items {
   216  		gwItem := gw.ItemInput{WebURI: webURI(item.SrcPath, args.Src, destTree, srcIsDir)}
   217  
   218  		if item.LinkTo != "" {
   219  			linkSrcDirRelative := path.Dir(getRelPath(item.SrcPath, args.Src))
   220  			linkSrcDirFull := path.Join(destTree, linkSrcDirRelative)
   221  			gwItem.LinkTo = path.Join(linkSrcDirFull, "/", item.LinkTo)
   222  		} else {
   223  			// Try to detect MIME type of file.
   224  			// mimetype will return "application/octet-stream" type if it
   225  			// can't make a determination or encounters an error.
   226  			mtype, err := mimetype.DetectFile(item.SrcPath)
   227  			logger.F(
   228  				"file", item.SrcPath,
   229  				"MIME type", mtype.String(),
   230  				"error", err,
   231  			).Debug("MIME type detection attempted")
   232  
   233  			gwItem.ObjectKey = item.Key
   234  			gwItem.ContentType = mtype.String()
   235  		}
   236  
   237  		publishItems = append(publishItems, gwItem)
   238  	}
   239  
   240  	err = publish.AddItems(ctx, publishItems)
   241  	if err != nil {
   242  		logger.F("error", err).Error("can't add items to publish")
   243  		return 51
   244  	}
   245  
   246  	logger.F("publish", publish.ID(), "items", len(publishItems)).Info("Added publish items")
   247  
   248  	shouldCommit, mode := commitMode(cfg, args)
   249  	if shouldCommit {
   250  		logger.F("publish", publish.ID(), "mode", mode).Info("Preparing to commit publish")
   251  		err = publish.Commit(ctx, mode)
   252  		if err != nil {
   253  			logger.F("error", err).Error("can't commit publish")
   254  			return 71
   255  		}
   256  	}
   257  
   258  	msg := "Completed successfully!"
   259  	if args.DryRun {
   260  		msg = "Completed successfully (in dry-run mode - no changes written)"
   261  	}
   262  	logger.Info(msg)
   263  
   264  	return 0
   265  
   266  }