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}