Add using Memory-mapped index reader
[teamcity/git-plugin.git] / git-server / src / jetbrains / buildServer / buildTriggers / vcs / git / GitServerUtil.java
1 /*
2  * Copyright 2000-2018 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16
17 package jetbrains.buildServer.buildTriggers.vcs.git;
18
19 import com.intellij.openapi.diagnostic.Logger;
20 import com.jcraft.jsch.JSchException;
21 import jetbrains.buildServer.ExecResult;
22 import jetbrains.buildServer.serverSide.FileWatchingPropertiesModel;
23 import jetbrains.buildServer.serverSide.TeamCityProperties;
24 import jetbrains.buildServer.util.FileUtil;
25 import jetbrains.buildServer.util.StringUtil;
26 import jetbrains.buildServer.vcs.VcsException;
27 import org.apache.log4j.ConsoleAppender;
28 import org.apache.log4j.Level;
29 import org.apache.log4j.PatternLayout;
30 import org.eclipse.jgit.errors.ConfigInvalidException;
31 import org.eclipse.jgit.errors.NotSupportedException;
32 import org.eclipse.jgit.errors.TransportException;
33 import org.eclipse.jgit.internal.JGitText;
34 import org.eclipse.jgit.internal.storage.file.MemoryMappedPackIndex;
35 import org.eclipse.jgit.internal.storage.file.PackIndex;
36 import org.eclipse.jgit.lib.*;
37 import org.eclipse.jgit.revwalk.RevCommit;
38 import org.eclipse.jgit.storage.file.FileBasedConfig;
39 import org.eclipse.jgit.storage.file.WindowCacheConfig;
40 import org.eclipse.jgit.transport.*;
41 import org.eclipse.jgit.util.FS;
42 import org.jetbrains.annotations.NotNull;
43 import org.jetbrains.annotations.Nullable;
44 import sun.awt.OSInfo;
45
46 import java.io.*;
47 import java.lang.management.ManagementFactory;
48 import java.lang.management.OperatingSystemMXBean;
49 import java.nio.charset.UnsupportedCharsetException;
50 import java.text.MessageFormat;
51 import java.util.*;
52 import java.util.stream.Collectors;
53
54 import static com.intellij.openapi.util.text.StringUtil.isEmpty;
55
56 /**
57  * Utilities for server part of the plugin
58  */
59 public class GitServerUtil {
60
61   public static final long KB = 1024;
62   public static final long MB = 1024 * KB;
63   public static final long GB = 1024 * MB;
64
65   private static Logger LOG = Logger.getInstance(GitServerUtil.class.getName());
66
67   /**
68    * Amount of characters displayed for in the display version of revision number
69    */
70   public static final int DISPLAY_VERSION_AMOUNT = 40;
71
72   public static void setupMemoryMappedIndexReading() {
73     if (TeamCityProperties.getBoolean("teamcity.server.git.useMemoryMappedIndex")) {
74       PackIndex.setPackIndexFactory(new MemoryMappedPackIndex());
75     }
76   }
77
78   /**
79    * Ensures that a bare repository exists at the specified path.
80    * If it does not, the directory is attempted to be created.
81    *
82    * @param dir    the path to the directory to init
83    * @param remote the remote URL
84    * @return a connection to repository
85    * @throws VcsException if the there is a problem with accessing VCS
86    */
87   public static Repository getRepository(@NotNull final File dir, @NotNull final URIish remote) throws VcsException {
88     if (dir.exists() && !dir.isDirectory()) {
89       throw new VcsException("The specified path is not a directory: " + dir);
90     }
91     try {
92       ensureRepositoryIsValid(dir);
93       Repository r = new RepositoryBuilder().setBare().setGitDir(dir).build();
94       String remoteUrl = remote.toString();
95       if (remoteUrl.contains("\n") || remoteUrl.contains("\r"))
96         throw new VcsException("Newline in url '" + remoteUrl + "'");
97       if (!new File(dir, "config").exists()) {
98         r.create(true);
99         final StoredConfig config = r.getConfig();
100         config.setString("teamcity", null, "remote", remoteUrl);
101         config.save();
102       } else {
103         final StoredConfig config = r.getConfig();
104         final String existingRemote = config.getString("teamcity", null, "remote");
105         if (existingRemote != null && !remoteUrl.equals(existingRemote)) {
106           throw getWrongUrlError(dir, existingRemote, remote);
107         } else if (existingRemote == null) {
108           config.setString("teamcity", null, "remote", remoteUrl);
109           config.save();
110         }
111       }
112       return r;
113     } catch (Exception ex) {
114       if (ex instanceof NullPointerException)
115         LOG.warn("The repository at directory '" + dir + "' cannot be opened or created", ex);
116       throw new VcsException("The repository at directory '" + dir + "' cannot be opened or created, reason: " + ex.toString(), ex);
117     }
118   }
119
120   @NotNull
121   static VcsException getWrongUrlError(@NotNull File dir, @NotNull String currentRemote, @NotNull URIish wrongRemote) {
122     return new VcsException(
123       "The specified directory " + dir + " is already used for another remote " + currentRemote +
124       " and cannot be used for others (" + wrongRemote.toString() + "). Please specify the other directory explicitly.");
125   }
126
127   private static void ensureRepositoryIsValid(File dir) throws InterruptedException, IOException, ConfigInvalidException {
128     File objectsDir = new File(dir, "objects");
129     if (objectsDir.exists()) {
130       File configFile = new File(dir, "config");
131       boolean valid = ensureConfigIsValid(configFile);
132       if (!valid) {
133         LOG.warn("Repository at '" + dir.getAbsolutePath() + "' has invalid config file, try to remove repository");
134         if (!FileUtil.delete(dir))
135           LOG.warn("Cannot remove repository at '" + dir.getAbsolutePath() + "', operations with such repository most likely will fail");
136       }
137     }
138   }
139
140   private static boolean ensureConfigIsValid(File configLocation) throws InterruptedException, IOException, ConfigInvalidException {
141     for (int i = 0; i < 3; i++) {
142       FileBasedConfig config = new FileBasedConfig(configLocation, FS.DETECTED);
143       config.load();
144       if (hasValidFormatVersion(config)) {
145         return true;
146       } else {
147         if (i < 2) {
148           LOG.warn("Config " + configLocation.getAbsolutePath() + " has invalid format version, will wait and check again");
149           Thread.sleep(2000);
150         } else {
151           LOG.warn("Config " + configLocation.getAbsolutePath() + " has invalid format version");
152         }
153       }
154     }
155     return false;
156   }
157
158
159   private static boolean hasValidFormatVersion(Config config) {
160     final String repositoryFormatVersion = config.getString(ConfigConstants.CONFIG_CORE_SECTION, null,
161                                                             ConfigConstants.CONFIG_KEY_REPO_FORMAT_VERSION);
162     return "0".equals(repositoryFormatVersion);
163   }
164
165   public static String getUser(GitVcsRoot root, RevCommit c) {
166     return getUser(root, c.getAuthorIdent());
167   }
168
169   public static String getUser(GitVcsRoot root, PersonIdent id) {
170     switch (root.getUsernameStyle()) {
171       case NAME:
172         return id.getName();
173       case EMAIL:
174         return id.getEmailAddress();
175       case FULL:
176         return getFullUserName(id);
177       case USERID:
178         String email = id.getEmailAddress();
179         final int i = email.lastIndexOf("@");
180         return email.substring(0, i > 0 ? i : email.length());
181       default:
182         throw new IllegalStateException("Unsupported username style: " + root.getUsernameStyle());
183     }
184   }
185
186   public static String getFullUserName(@NotNull final PersonIdent id) {
187     return id.getName() + " <" + id.getEmailAddress() + ">";
188   }
189
190   /**
191    * Create display version for the commit
192    *
193    * @param version the version to examine
194    * @return the display version
195    */
196   public static String displayVersion(String version) {
197     return version.substring(0, DISPLAY_VERSION_AMOUNT);
198   }
199
200
201   public static Exception friendlyTransportException(@NotNull TransportException te, @NotNull GitVcsRoot root) {
202     if (isUnknownHostKeyError(te)) {
203       String originalMessage = te.getMessage();
204       String message = originalMessage + ". Add this host to a known hosts database or check option 'Ignore Known Hosts Database'.";
205       return new VcsException(message, te);
206     }
207
208     if (root.isOnGithub()) {
209       if (isWrongGithubUsername(te, root)) {
210         String message = "Wrong username: '" + root.getAuthSettings().getUserName() + "', GitHub expects the 'git' username";
211         return new VcsException(message, te);
212       }
213       if (root.isHttp() && !root.getRepositoryFetchURL().getPath().endsWith(".git") &&
214           te.getMessage().contains("service=git-upload-pack not found")) {
215         String url = root.getRepositoryFetchURL().toString();
216         String message = "Url \"" + url + "\" might be incorrect, try using \"" + url + ".git\"";
217         return new VcsException(message, te);
218       }
219     }
220
221     return te;
222   }
223
224
225   private static boolean isWrongGithubUsername(@NotNull TransportException te, @NotNull GitVcsRoot root) {
226     return root.isSsh() && isGithubSshAuthError(te) && !"git".equals(root.getAuthSettings().getUserName());
227   }
228
229
230   private static boolean isGithubSshAuthError(@NotNull TransportException e) {
231     Throwable cause = e.getCause();
232     return cause instanceof JSchException &&
233            ("Auth fail".equals(cause.getMessage()) ||
234             "session is down".equals(cause.getMessage()));
235   }
236
237
238   static boolean isAuthError(@NotNull VcsException e) {
239     String msg = e.getMessage();
240     return msg != null && msg.contains("not authorized");
241   }
242
243
244   @NotNull
245   public static NotSupportedException friendlyNotSupportedException(@NotNull GitVcsRoot root, @NotNull NotSupportedException nse)  {
246     URIish fetchURI = root.getRepositoryFetchURL();
247     if (isRedundantColon(fetchURI)) {
248       //url with username looks like ssh://username/hostname:/path/to/repo - it will
249       //confuse user even further, so show url without user name
250       return new NotSupportedException(MessageFormat.format(JGitText.get().URINotSupported, root.getProperty(Constants.FETCH_URL)) +
251                                       ". Make sure you don't have a colon after the host name.");
252     } else {
253       return nse;
254     }
255   }
256
257
258   private static boolean isUnknownHostKeyError(TransportException error) {
259     String message = error.getMessage();
260     return message != null && message.contains("UnknownHostKey") && message.contains("key fingerprint is");
261   }
262
263
264   /**
265    * Test if uri contains a common error -- redundant colon after hostname.
266    *
267    * Example of incorrect uri:
268    *
269    * ssh://hostname:/path/to/repo.git
270    *
271    * ':' after hostname is redundant.
272    *
273    * URIish doesn't throw an exception for such uri in its constructor (see
274    * https://bugs.eclipse.org/bugs/show_bug.cgi?id=315571 for explanation why),
275    * exception is thrown only on attempt to open transport.
276    *
277    * @param uri uri to check
278    * @return true if uri contains this error
279    */
280   private static boolean isRedundantColon(URIish uri) {
281     return "ssh".equals(uri.getScheme()) &&
282            uri.getHost() == null &&
283            uri.getPath() != null && uri.getPath().contains(":");
284   }
285
286
287   /**
288    * Check all refs successfully updated, throws exception if they are not
289    * @param result fetch result
290    * @throws VcsException if any ref was not successfully updated
291    */
292   public static void checkFetchSuccessful(Repository db, FetchResult result) throws VcsException {
293     for (TrackingRefUpdate update : result.getTrackingRefUpdates()) {
294       String localRefName = update.getLocalName();
295       RefUpdate.Result status = update.getResult();
296       if (status == RefUpdate.Result.REJECTED || status == RefUpdate.Result.LOCK_FAILURE || status == RefUpdate.Result.IO_FAILURE) {
297         if (status == RefUpdate.Result.LOCK_FAILURE) {
298           TreeSet<String> caseSensitiveConflicts = new TreeSet<>();
299           TreeSet<String> conflicts = new TreeSet<>();
300           try {
301             OSInfo.OSType os = OSInfo.getOSType();
302             if (os == OSInfo.OSType.WINDOWS || os == OSInfo.OSType.MACOSX) {
303               Set<String> refNames = db.getRefDatabase().getRefs(RefDatabase.ALL).keySet();
304               for (String ref : refNames) {
305                 if (!localRefName.equals(ref) && localRefName.equalsIgnoreCase(ref))
306                   caseSensitiveConflicts.add(ref);
307               }
308             }
309             conflicts.addAll(db.getRefDatabase().getConflictingNames(localRefName));
310           } catch (Exception e) {
311             //ignore
312           }
313           String msg;
314           if (!conflicts.isEmpty()) {
315             msg = "Failed to fetch ref " + localRefName + ": it clashes with " + StringUtil.join(", ", conflicts) +
316                   ". Please remove conflicting refs from repository.";
317           } else if (!caseSensitiveConflicts.isEmpty()) {
318             msg = "Failed to fetch ref " + localRefName + ": on case-insensitive file system it clashes with " +
319                   StringUtil.join(", ", caseSensitiveConflicts) +
320                   ". Please remove conflicting refs from repository.";
321           } else {
322             msg = "Fail to update '" + localRefName + "' (" + status.name() + ")";
323           }
324           throw new VcsException(msg);
325         } else {
326           throw new VcsException("Fail to update '" + localRefName + "' (" + status.name() + ")");
327         }
328       }
329     }
330   }
331
332
333   static void pruneRemovedBranches(@NotNull ServerPluginConfig config,
334                                    @NotNull TransportFactory transportFactory,
335                                    @NotNull Transport tn,
336                                    @NotNull Repository db,
337                                    @NotNull URIish uri,
338                                    @NotNull AuthSettings authSettings) throws IOException, VcsException {
339     if (config.createNewConnectionForPrune()) {
340       Transport transport = null;
341       try {
342         transport = transportFactory.createTransport(db, uri, authSettings, config.getRepositoryStateTimeoutSeconds());
343         pruneRemovedBranches(db, transport);
344       } finally {
345         if (transport != null)
346           transport.close();
347       }
348     } else {
349       pruneRemovedBranches(db, tn);
350     }
351   }
352
353   /**
354    * Removes branches of a bare repository which are not present in a remote repository
355    */
356   private static void pruneRemovedBranches(@NotNull Repository db, @NotNull Transport tn) {
357     FetchConnection conn = null;
358     try {
359       conn = tn.openFetch();
360       Map<String, Ref> remoteRefMap = conn.getRefsMap();
361       for (Map.Entry<String, Ref> e : db.getAllRefs().entrySet()) {
362         if (!remoteRefMap.containsKey(e.getKey())) {
363           try {
364             RefUpdate refUpdate = db.getRefDatabase().newUpdate(e.getKey(), false);
365             refUpdate.setForceUpdate(true);
366             refUpdate.delete();
367           } catch (Exception ex) {
368             LOG.info("Failed to prune removed ref " + e.getKey(), ex);
369             break;
370           }
371         }
372       }
373     } catch (IOException e) {
374       LOG.info("Failed to list remote refs, continue without pruning removed refs", e);
375     } finally {
376       if (conn != null)
377         conn.close();
378     }
379   }
380
381
382   public static boolean isCloned(@NotNull Repository db) throws VcsException, IOException {
383     if (!db.getObjectDatabase().exists())
384       return false;
385     ObjectReader reader = db.getObjectDatabase().newReader();
386     try {
387       for (Ref ref : db.getRefDatabase().getRefs(RefDatabase.ALL).values()) {
388         if (reader.has(ref.getObjectId()))
389           return true;
390       }
391     } finally {
392       reader.release();
393     }
394     return false;
395   }
396
397
398   /**
399    * Read input from System.in until it closed
400    *
401    * @return input as string
402    * @throws IOException
403    */
404   public static String readInput() throws IOException {
405     char[] chars = new char[512];
406     StringBuilder sb = new StringBuilder();
407     Reader processInput = new BufferedReader(new InputStreamReader(System.in, "UTF-8"));
408     int count = 0;
409     while ((count = processInput.read(chars)) != -1) {
410       final String str = new String(chars, 0, count);
411       sb.append(str);
412     }
413     return sb.toString();
414   }
415
416
417   public static void configureInternalProperties(@NotNull final File internalProperties) {
418     new TeamCityProperties() {{
419       setModel(new FileWatchingPropertiesModel(internalProperties));
420     }};
421   }
422
423
424   public static void configureStreamFileThreshold(int thresholdBytes) {
425     WindowCacheConfig cfg = new WindowCacheConfig();
426     cfg.setStreamFileThreshold(thresholdBytes);
427     cfg.install();
428   }
429
430
431   public static void configureExternalProcessLogger(boolean debugEnabled) {
432     org.apache.log4j.Logger.getRootLogger().addAppender(new ConsoleAppender(new PatternLayout("[%d] %6p - %30.30c - %m %n")));
433     org.apache.log4j.Logger.getRootLogger().setLevel(Level.INFO);
434     org.apache.log4j.Logger.getLogger("org.eclipse.jgit").setLevel(debugEnabled ? Level.DEBUG : Level.OFF);
435     org.apache.log4j.Logger.getLogger("jetbrains.buildServer.buildTriggers.vcs.git").setLevel(debugEnabled ? Level.DEBUG : Level.INFO);
436   }
437
438
439   public static void writeAsProperties(@NotNull File f, @NotNull Map<String, String> props) throws IOException {
440     StringBuilder sb = new StringBuilder();
441     for (Map.Entry<String, String> e : props.entrySet()) {
442       if (!isEmpty(e.getValue()))
443         sb.append(e.getKey()).append("=").append(e.getValue()).append("\n");
444     }
445     FileUtil.writeFileAndReportErrors(f, sb.toString());
446   }
447
448
449   public static boolean isCannotCreateJvmError(@NotNull ExecResult result) {
450     return result.getStderr().contains("Could not create the Java Virtual Machine");
451   }
452
453   @Nullable
454   public static Long convertMemorySizeToBytes(@Nullable String memory) {
455     if (memory == null)
456       return null;
457     memory = memory.trim();
458     if (memory.isEmpty())
459       return null;
460     int unit = memory.charAt(memory.length() - 1);
461     long amount;
462     try {
463       amount = Long.parseLong(memory.substring(0, memory.length() - 1));
464     } catch (NumberFormatException e) {
465       return null;
466     }
467     switch (unit) {
468       case 'k':
469       case 'K':
470         return amount * KB;
471       case 'm':
472       case 'M':
473         return amount * MB;
474       case 'g':
475       case 'G':
476         return amount * GB;
477       default:
478         return null;
479     }
480   }
481
482
483   @Nullable
484   public static Long getFreePhysicalMemorySize() {
485     try {
486       Class.forName("com.sun.management.OperatingSystemMXBean");
487     } catch (ClassNotFoundException e) {
488       return null;
489     }
490     OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
491     if (osBean instanceof com.sun.management.OperatingSystemMXBean) {
492       return ((com.sun.management.OperatingSystemMXBean) osBean).getFreePhysicalMemorySize();
493     }
494     return null;
495
496   }
497
498
499   public static boolean isAmazonCodeCommit(@Nullable String host, @NotNull ServerPluginConfig config) {
500     if (host == null)
501       return false;
502     if (host.startsWith("git-codecommit") && host.endsWith("amazonaws.com"))
503       return true;
504     List<String> amazonHosts = config.getAmazonHosts();
505     return amazonHosts.contains(host);
506   }
507
508
509   @NotNull
510   public static FetchResult fetch(@NotNull Repository r,
511                                   @NotNull URIish url,
512                                   @NotNull AuthSettings authSettings,
513                                   @NotNull TransportFactory transportFactory,
514                                   @NotNull Transport transport,
515                                   @NotNull ProgressMonitor progress,
516                                   @NotNull Collection<RefSpec> refSpecs,
517                                   boolean ignoreMissingRemoteRef) throws NotSupportedException, TransportException, VcsException {
518     try {
519       return transport.fetch(progress, refSpecs);
520     } catch (TransportException e) {
521       Throwable cause = e.getCause();
522       if (cause instanceof JSchException && "channel is not opened.".equals(cause.getMessage())) {
523         return runWithNewTransport(r, url, authSettings, transportFactory, tn -> tn.fetch(progress, refSpecs));
524       } if ("http".equals(url.getScheme()) && url.getHost().contains("github.com") &&
525             e.getMessage() != null && e.getMessage().contains("301")) {
526         /* github returns 301 status code in case we use http protocol */
527         throw new TransportException("Please use https protocol in VCS root instead of http.", e);
528       } else {
529         if (ignoreMissingRemoteRef) {
530           String missingRef = getMissingRemoteRef(e);
531           if (missingRef != null) {
532             //exclude spec causing the error
533             List<RefSpec> updatedSpecs = refSpecs.stream().filter(spec -> !spec.getSource().equals(missingRef)).collect(Collectors.toList());
534             if (updatedSpecs.size() == refSpecs.size())
535               throw e;
536             return runWithNewTransport(r, url, authSettings, transportFactory, tn ->
537               fetch(r, url, authSettings, transportFactory, tn, progress, updatedSpecs, ignoreMissingRemoteRef));
538           }
539         }
540         throw e;
541       }
542     }
543   }
544
545
546   private interface FetchAction {
547     @NotNull
548     FetchResult run(@NotNull Transport t) throws NotSupportedException, VcsException, TransportException;
549   }
550
551   private static FetchResult runWithNewTransport(@NotNull Repository r,
552                                                  @NotNull URIish url,
553                                                  @NotNull AuthSettings authSettings,
554                                                  @NotNull TransportFactory transportFactory,
555                                                  @NotNull FetchAction action) throws NotSupportedException, VcsException, TransportException {
556     Transport tn = null;
557     try {
558       tn = transportFactory.createTransport(r, url, authSettings);
559       return action.run(tn);
560     } finally {
561       if (tn != null)
562         tn.close();
563     }
564   }
565
566
567   @Nullable
568   private static String getMissingRemoteRef(@NotNull TransportException error) {
569     String msg = error.getMessage();
570     if (msg.startsWith("Remote does not have") && msg.endsWith("available for fetch.")) {
571       return msg.substring("Remote does not have".length(), msg.indexOf("available for fetch.")).trim();
572     }
573     return null;
574   }
575
576
577   @NotNull
578   public static String getFullMessage(@NotNull RevCommit commit) {
579     try {
580       return commit.getFullMessage();
581     } catch (UnsupportedCharsetException e) {
582       LOG.warn("Cannot parse the " + commit.name() + " commit message due to unknown commit encoding '" + e.getCharsetName() + "'");
583       return "Cannot parse commit message due to unknown commit encoding '" + e.getCharsetName() + "'";
584     }
585   }
586
587
588   public static PersonIdent getAuthorIdent(@NotNull RevCommit commit) {
589     try {
590       return commit.getAuthorIdent();
591     } catch (UnsupportedCharsetException e) {
592       LOG.warn("Cannot parse the " + commit.name() + " commit author due to unknown commit encoding '" + e.getCharsetName() + "'");
593       return new PersonIdent("Cannot parse author", "Cannot parse author");
594     }
595   }
596 }