001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.server.config.repository;
018
019import java.io.*;
020
021import org.apache.juneau.commons.inject.*;
022import org.apache.juneau.commons.logging.*;
023import org.apache.juneau.commons.secret.*;
024import org.eclipse.jgit.api.*;
025import org.eclipse.jgit.api.errors.*;
026import org.eclipse.jgit.internal.storage.file.*;
027import org.eclipse.jgit.lib.*;
028import org.eclipse.jgit.transport.*;
029
030
031/**
032 * Git repository control operations for cloning, pulling, pushing, and committing.
033 *
034 * <p>
035 * Provides a simplified wrapper around JGit for server configuration repository management.
036 * Used to fetch configuration files from a remote Git repository.
037 *
038 * <p>
039 * Credentials are never hardcoded in this class.  They are either supplied directly by the caller (via the 5-arg
040 * constructor) or, preferably, resolved at construction time from a pluggable {@link SecretStore} (via the
041 * {@link #GitControl(String, String, String, String, BeanStore, boolean) BeanStore/SecretStore} constructor) so the
042 * password/token can be sourced from an environment variable, an OS keychain, or another secret backend rather than a
043 * literal string.
044 *
045 * <p>
046 * The underlying JGit {@link Repository} and {@link Git} handles are opened in the constructor and released by
047 * {@link #close()}, so instances must be used with try-with-resources to avoid leaking file/native handles.
048 *
049 * <h5 class='section'>See Also:</h5><ul>
050 *    <li class='jc'>{@link SecretStore}
051 *    <li class='jc'>{@link SecretStores}
052 * </ul>
053 */
054@SuppressWarnings({
055   "resource" // The JGit Repository and Git handles are opened in the constructors and released by close(); callers use GitControl via try-with-resources
056})
057public class GitControl implements AutoCloseable {
058
059   private final String localPath;
060   private final String remotePath;
061   private final Repository localRepo;
062   private final Git git;
063   private final CredentialsProvider cp;
064   private final boolean forcePush;
065
066   /**
067    * Constructor.
068    *
069    * <p>
070    * Equivalent to {@link #GitControl(String, String, String, String, boolean) GitControl(localPath, remotePath,
071    * null, null, false)}: no credentials (anonymous transport) and non-force push.
072    *
073    * @param localPath Local directory path for the repository.
074    * @param remotePath Remote Git repository URI.
075    * @throws IOException If the repository cannot be opened.
076    */
077   public GitControl(String localPath, String remotePath) throws IOException {
078      this(localPath, remotePath, null, null, false);
079   }
080
081   /**
082    * Constructor.
083    *
084    * <p>
085    * Credentials are supplied by the caller (typically from operator configuration or a secret store) rather than
086    * hardcoded.  Pass {@code null} for {@code username} to use anonymous transport.
087    *
088    * @param localPath Local directory path for the repository.
089    * @param remotePath Remote Git repository URI.
090    * @param username Git username, or <jk>null</jk> for anonymous transport.
091    * @param password Git password/token.  Ignored when {@code username} is <jk>null</jk>.
092    * @param forcePush Whether {@link #pushToRepo()} performs a force-push (destructive remote history rewrite).
093    *    <br>Force-push is opt-in; defaults to <jk>false</jk> in the other constructors.
094    * @throws IOException If the repository cannot be opened.
095    */
096   public GitControl(String localPath, String remotePath, String username, String password, boolean forcePush) throws IOException {
097      this.localPath = localPath;
098      this.remotePath = remotePath;
099      this.localRepo = new FileRepository(localPath + "/.git");
100      this.cp = username == null ? null : new UsernamePasswordCredentialsProvider(username, password);
101      this.forcePush = forcePush;
102      git = new Git(localRepo);
103   }
104
105   /**
106    * Constructor that resolves the Git password/token from a pluggable {@link SecretStore}.
107    *
108    * <p>
109    * The active store is resolved from the supplied {@link BeanStore} via {@link SecretStores#resolve(BeanStore)},
110    * defaulting to an {@link InMemorySecretStore} when the bean store is <jk>null</jk> or contributes none.  The
111    * password/token is looked up under <jv>secretKey</jv> as a {@code char[]} so it is never materialized as a
112    * {@link String}, and the credentials are zeroed by {@link #close()}.  Pass <jk>null</jk> for <jv>username</jv> to
113    * use anonymous transport (the secret store is not consulted in that case).
114    *
115    * @param localPath Local directory path for the repository.
116    * @param remotePath Remote Git repository URI.
117    * @param username Git username, or <jk>null</jk> for anonymous transport.
118    * @param secretKey The key under which the password/token is stored in the resolved {@link SecretStore}.
119    *    <br>Ignored when <jv>username</jv> is <jk>null</jk>.
120    * @param beanStore The bean store to resolve the {@link SecretStore} from.  Can be <jk>null</jk> to use the
121    *    default {@link InMemorySecretStore}.
122    * @param forcePush Whether {@link #pushToRepo()} performs a force-push (destructive remote history rewrite).
123    *    <br>Force-push is opt-in; defaults to <jk>false</jk> in the other constructors.
124    * @throws IOException If the repository cannot be opened.
125    */
126   public GitControl(String localPath, String remotePath, String username, String secretKey, BeanStore beanStore, boolean forcePush) throws IOException {
127      this.localPath = localPath;
128      this.remotePath = remotePath;
129      this.localRepo = new FileRepository(localPath + "/.git");
130      this.cp = findCredentialsProvider(username, secretKey, beanStore);
131      this.forcePush = forcePush;
132      git = new Git(localRepo);
133   }
134
135   /**
136    * Builds the credentials provider by resolving the password/token from the {@link SecretStore} contributed to the
137    * supplied bean store.
138    *
139    * <p>
140    * Returns <jk>null</jk> (anonymous transport) when <jv>username</jv> is <jk>null</jk>.  When the secret is absent
141    * from the resolved store an empty password is used, so a caller cannot accidentally send a stale literal.  The
142    * retrieved {@code char[]} is handed to the provider and zeroed by {@link #close()}.
143    *
144    * @param username Git username, or <jk>null</jk> for anonymous transport.
145    * @param secretKey The key under which the password/token is stored.
146    * @param beanStore The bean store to resolve the {@link SecretStore} from.  Can be <jk>null</jk>.
147    * @return The credentials provider, or <jk>null</jk> for anonymous transport.
148    */
149   static CredentialsProvider findCredentialsProvider(String username, String secretKey, BeanStore beanStore) {
150      if (username == null)
151         return null;
152      var secret = SecretStores.resolve(beanStore).find(secretKey).orElseGet(() -> new char[0]);
153      return new UsernamePasswordCredentialsProvider(username, secret);
154   }
155
156   /**
157    * Closes the underlying JGit {@link Git} and {@link Repository} handles.
158    *
159    * <p>
160    * The {@link Git} instance wraps an externally-supplied {@link Repository} (it does not close it on
161    * {@link Git#close()}), so both are released explicitly here.  Any credentials held by the provider are zeroed so
162    * a resolved secret does not linger in memory beyond the life of this instance.
163    */
164   @Override
165   public void close() {
166      git.close();
167      localRepo.close();
168      if (cp instanceof UsernamePasswordCredentialsProvider cp2)
169         cp2.clear();
170   }
171
172   /**
173    * Stages all files for commit.
174    *
175    * @throws GitAPIException If staging fails.
176    */
177   public void addToRepo() throws GitAPIException {
178      var add = git.add();
179      add.addFilepattern(".").call();
180   }
181
182   /**
183    * Checks out the specified branch from origin.
184    *
185    * @param name Branch name.  Must not be <jk>null</jk>.
186    * @throws GitAPIException If checkout fails.
187    */
188   public void branch(String name) throws GitAPIException {
189      git.checkout().setName(name).setStartPoint("origin/".concat(name)).call();
190   }
191
192   /**
193    * Clones the remote repository to the local path.
194    *
195    * @throws GitAPIException If clone fails.
196    */
197   public void cloneRepo() throws GitAPIException {
198      // call() returns a Git handle wrapping the freshly-cloned repo; the clone (working tree on disk) is the
199      // side-effect we want, so close the returned handle immediately to avoid leaking file/native handles.
200      try (var g = Git.cloneRepository().setURI(remotePath).setDirectory(new File(localPath)).call()) {
201         // No further operations needed on the cloned handle.
202      }
203   }
204
205   /**
206    * Commits staged changes with the specified message.
207    *
208    * @param message Commit message.
209    * @throws JGitInternalException If an internal JGit error occurs.
210    * @throws GitAPIException If commit fails.
211    */
212   public void commitToRepo(String message)
213      throws JGitInternalException, GitAPIException {
214      git.commit().setMessage(message).call();
215   }
216
217   /**
218    * Pulls the latest changes from the remote repository.
219    *
220    * @throws GitAPIException If pull fails.
221    */
222   public void pullFromRepo() throws GitAPIException {
223      git.pull().call();
224   }
225
226   /**
227    * Pushes all committed changes to the remote repository.
228    *
229    * @throws JGitInternalException If an internal JGit error occurs.
230    * @throws GitAPIException If push fails.
231    */
232   public void pushToRepo() throws JGitInternalException, GitAPIException {
233      var pc = git.push();
234      if (cp != null)
235         pc.setCredentialsProvider(cp);
236      // Force-push is opt-in (see constructor); a destructive remote history rewrite is never performed by default.
237      pc.setForce(forcePush).setPushAll();
238      try {
239         var it = pc.call().iterator();
240         if (it.hasNext()) {
241            RichLogger.getLogger(GitControl.class).info("%s", it.next().toString());
242         }
243      } catch (InvalidRemoteException e) {
244         RichLogger.getLogger(GitControl.class).warning(e, "Error pushing to remote repository.");
245      }
246   }
247}