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 }