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