View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.buildcache;
20  
21  import javax.annotation.Priority;
22  import javax.inject.Inject;
23  import javax.inject.Named;
24  
25  import java.io.File;
26  import java.nio.file.Path;
27  import java.util.HashSet;
28  import java.util.List;
29  import java.util.Map;
30  import java.util.Set;
31  
32  import org.apache.commons.lang3.ArrayUtils;
33  import org.apache.commons.lang3.StringUtils;
34  import org.apache.maven.SessionScoped;
35  import org.apache.maven.buildcache.artifact.ArtifactRestorationReport;
36  import org.apache.maven.buildcache.checksum.MavenProjectInput;
37  import org.apache.maven.buildcache.xml.Build;
38  import org.apache.maven.buildcache.xml.CacheConfig;
39  import org.apache.maven.buildcache.xml.CacheState;
40  import org.apache.maven.buildcache.xml.DtoUtils;
41  import org.apache.maven.buildcache.xml.build.CompletedExecution;
42  import org.apache.maven.buildcache.xml.config.TrackedProperty;
43  import org.apache.maven.execution.MavenSession;
44  import org.apache.maven.execution.MojoExecutionEvent;
45  import org.apache.maven.execution.scope.internal.MojoExecutionScope;
46  import org.apache.maven.lifecycle.LifecycleExecutionException;
47  import org.apache.maven.plugin.MavenPluginManager;
48  import org.apache.maven.plugin.Mojo;
49  import org.apache.maven.plugin.MojoExecution;
50  import org.apache.maven.plugin.MojoExecution.Source;
51  import org.apache.maven.plugin.MojoExecutionException;
52  import org.apache.maven.plugin.MojoExecutionRunner;
53  import org.apache.maven.plugin.MojosExecutionStrategy;
54  import org.apache.maven.plugin.PluginConfigurationException;
55  import org.apache.maven.plugin.PluginContainerException;
56  import org.apache.maven.project.MavenProject;
57  import org.codehaus.plexus.util.ReflectionUtils;
58  import org.slf4j.Logger;
59  import org.slf4j.LoggerFactory;
60  
61  import static org.apache.maven.buildcache.CacheUtils.mojoExecutionKey;
62  import static org.apache.maven.buildcache.checksum.KeyUtils.getVersionlessProjectKey;
63  import static org.apache.maven.buildcache.xml.CacheState.DISABLED;
64  import static org.apache.maven.buildcache.xml.CacheState.INITIALIZED;
65  
66  /**
67   * Build cache-enabled version of the {@link MojosExecutionStrategy}.
68   */
69  @SessionScoped
70  @Named
71  @Priority(10)
72  @SuppressWarnings("unused")
73  public class BuildCacheMojosExecutionStrategy implements MojosExecutionStrategy {
74  
75      private static final Logger LOGGER = LoggerFactory.getLogger(BuildCacheMojosExecutionStrategy.class);
76  
77      private final CacheController cacheController;
78      private final CacheConfig cacheConfig;
79      private final MojoParametersListener mojoListener;
80      private final LifecyclePhasesHelper lifecyclePhasesHelper;
81      private final MavenPluginManager mavenPluginManager;
82      private MojoExecutionScope mojoExecutionScope;
83  
84      @Inject
85      public BuildCacheMojosExecutionStrategy(
86              CacheController cacheController,
87              CacheConfig cacheConfig,
88              MojoParametersListener mojoListener,
89              LifecyclePhasesHelper lifecyclePhasesHelper,
90              MavenPluginManager mavenPluginManager,
91              MojoExecutionScope mojoExecutionScope) {
92          this.cacheController = cacheController;
93          this.cacheConfig = cacheConfig;
94          this.mojoListener = mojoListener;
95          this.lifecyclePhasesHelper = lifecyclePhasesHelper;
96          this.mavenPluginManager = mavenPluginManager;
97          this.mojoExecutionScope = mojoExecutionScope;
98      }
99  
100     public void execute(
101             List<MojoExecution> mojoExecutions, MavenSession session, MojoExecutionRunner mojoExecutionRunner)
102             throws LifecycleExecutionException {
103         try {
104             final MavenProject project = session.getCurrentProject();
105             final Source source = getSource(mojoExecutions);
106 
107             // execute clean bound goals before restoring to not interfere/slowdown clean
108             CacheState cacheState = DISABLED;
109             CacheResult result = CacheResult.empty();
110             boolean skipCache = cacheConfig.isSkipCache() || MavenProjectInput.isSkipCache(project);
111             boolean cacheIsDisabled = MavenProjectInput.isCacheDisabled(project);
112             // Forked execution should be thought as a part of originating mojo internal implementation
113             // If forkedExecution is detected, it means that originating mojo is not cached so forks should rerun too
114             boolean forkedExecution = lifecyclePhasesHelper.isForkedProject(project);
115             List<MojoExecution> cleanPhase = null;
116             if (source == Source.LIFECYCLE && !forkedExecution) {
117                 cleanPhase = lifecyclePhasesHelper.getCleanSegment(project, mojoExecutions);
118                 for (MojoExecution mojoExecution : cleanPhase) {
119                     mojoExecutionRunner.run(mojoExecution);
120                 }
121                 if (!cacheIsDisabled) {
122                     cacheState = cacheConfig.initialize();
123                 } else {
124                     LOGGER.info(
125                             "Cache is explicitly disabled on project level for {}", getVersionlessProjectKey(project));
126                 }
127                 if (cacheState == INITIALIZED || skipCache) {
128                     result = cacheController.findCachedBuild(session, project, mojoExecutions, skipCache);
129                 }
130             }
131 
132             boolean restorable = result.isSuccess() || result.isPartialSuccess();
133             boolean restored = false; // if partially restored need to save increment
134             if (restorable) {
135                 CacheRestorationStatus cacheRestorationStatus =
136                         restoreProject(result, mojoExecutions, mojoExecutionRunner, cacheConfig);
137                 restored = CacheRestorationStatus.SUCCESS == cacheRestorationStatus;
138                 executeExtraCleanPhaseIfNeeded(cacheRestorationStatus, cleanPhase, mojoExecutionRunner);
139             }
140             if (!restored) {
141                 for (MojoExecution mojoExecution : mojoExecutions) {
142                     if (source == Source.CLI
143                             || mojoExecution.getLifecyclePhase() == null
144                             || lifecyclePhasesHelper.isLaterPhaseThanClean(mojoExecution.getLifecyclePhase())) {
145                         mojoExecutionRunner.run(mojoExecution);
146                     }
147                 }
148             }
149 
150             if (cacheState == INITIALIZED && (!result.isSuccess() || !restored)) {
151                 if (cacheConfig.isSkipSave()) {
152                     LOGGER.info("Cache saving is disabled.");
153                 } else if (cacheConfig.isMandatoryClean()
154                         && lifecyclePhasesHelper
155                                 .getCleanSegment(project, mojoExecutions)
156                                 .isEmpty()) {
157                     LOGGER.info("Cache storing is skipped since there was no \"clean\" phase.");
158                 } else {
159                     final Map<String, MojoExecutionEvent> executionEvents = mojoListener.getProjectExecutions(project);
160                     cacheController.save(result, mojoExecutions, executionEvents);
161                 }
162             }
163 
164             if (cacheConfig.isFailFast() && !result.isSuccess() && !skipCache && !forkedExecution) {
165                 throw new LifecycleExecutionException(
166                         "Failed to restore project[" + getVersionlessProjectKey(project)
167                                 + "] from cache, failing build.",
168                         project);
169             }
170         } catch (MojoExecutionException e) {
171             throw new LifecycleExecutionException(e.getMessage(), e);
172         }
173     }
174 
175     /**
176      * Cache configuration could demand to restore some files in the project directory (generated sources or even arbitrary content)
177      * If an error occurs during or after this kind of restoration AND a clean phase was required in the build :
178      * we execute an extra clean phase to remove any potential partially restored files
179      *
180      * @param cacheRestorationStatus the restoration status
181      * @param cleanPhase clean phase mojos
182      * @param mojoExecutionRunner mojo runner
183      * @throws LifecycleExecutionException
184      */
185     private void executeExtraCleanPhaseIfNeeded(
186             final CacheRestorationStatus cacheRestorationStatus,
187             List<MojoExecution> cleanPhase,
188             MojoExecutionRunner mojoExecutionRunner)
189             throws LifecycleExecutionException {
190         if (CacheRestorationStatus.FAILURE_NEEDS_CLEAN == cacheRestorationStatus
191                 && cleanPhase != null
192                 && !cleanPhase.isEmpty()) {
193             LOGGER.info("Extra clean phase is executed as cache could be partially restored.");
194             for (MojoExecution mojoExecution : cleanPhase) {
195                 mojoExecutionRunner.run(mojoExecution);
196             }
197         }
198     }
199 
200     private Source getSource(List<MojoExecution> mojoExecutions) {
201         if (mojoExecutions == null || mojoExecutions.isEmpty()) {
202             return null;
203         }
204         for (MojoExecution mojoExecution : mojoExecutions) {
205             if (mojoExecution.getSource() == Source.CLI) {
206                 return Source.CLI;
207             }
208         }
209         return Source.LIFECYCLE;
210     }
211 
212     private CacheRestorationStatus restoreProject(
213             CacheResult cacheResult,
214             List<MojoExecution> mojoExecutions,
215             MojoExecutionRunner mojoExecutionRunner,
216             CacheConfig cacheConfig)
217             throws LifecycleExecutionException, MojoExecutionException {
218         mojoExecutionScope.enter();
219         try {
220             final Build build = cacheResult.getBuildInfo();
221             final MavenProject project = cacheResult.getContext().getProject();
222             final MavenSession session = cacheResult.getContext().getSession();
223             mojoExecutionScope.seed(MavenProject.class, project);
224             mojoExecutionScope.seed(MavenSession.class, session);
225             final List<MojoExecution> cachedSegment =
226                     lifecyclePhasesHelper.getCachedSegment(project, mojoExecutions, build);
227 
228             // Verify cache consistency for cached mojos
229             LOGGER.debug("Verify consistency on cached mojos");
230             Set<MojoExecution> forcedExecutionMojos = new HashSet<>();
231             for (MojoExecution cacheCandidate : cachedSegment) {
232                 if (cacheController.isForcedExecution(project, cacheCandidate)) {
233                     forcedExecutionMojos.add(cacheCandidate);
234                 } else {
235                     if (!verifyCacheConsistency(
236                             cacheCandidate, build, project, session, mojoExecutionRunner, cacheConfig)) {
237                         LOGGER.info("A cached mojo is not consistent, continuing with non cached build");
238                         return CacheRestorationStatus.FAILURE;
239                     }
240                 }
241             }
242 
243             // Restore project artifacts
244             ArtifactRestorationReport restorationReport = cacheController.restoreProjectArtifacts(cacheResult);
245             if (!restorationReport.isSuccess()) {
246                 LOGGER.info("Cannot restore project artifacts, continuing with non cached build");
247                 return restorationReport.isRestoredFilesInProjectDirectory()
248                         ? CacheRestorationStatus.FAILURE_NEEDS_CLEAN
249                         : CacheRestorationStatus.FAILURE;
250             }
251 
252             // Execute mandatory mojos (forced by configuration)
253             LOGGER.debug("Execute mandatory mojos in the cache segment");
254             for (MojoExecution cacheCandidate : cachedSegment) {
255                 if (forcedExecutionMojos.contains(cacheCandidate)) {
256                     LOGGER.info(
257                             "Mojo execution is forced by project property: {}",
258                             cacheCandidate.getMojoDescriptor().getFullGoalName());
259                     mojoExecutionScope.seed(MojoExecution.class, cacheCandidate);
260                     // need maven 4 as minumum
261                     // mojoExecutionScope.seed(
262                     //        org.apache.maven.api.plugin.Log.class,
263                     //        new DefaultLog(LoggerFactory.getLogger(
264                     //                cacheCandidate.getMojoDescriptor().getFullGoalName())));
265                     // mojoExecutionScope.seed(Project.class, ((DefaultSession)
266                     // session.getSession()).getProject(project));
267                     // mojoExecutionScope.seed(
268                     //        org.apache.maven.api.MojoExecution.class, new DefaultMojoExecution(cacheCandidate));
269                     mojoExecutionRunner.run(cacheCandidate);
270                 } else {
271                     LOGGER.info(
272                             "Skipping plugin execution (cached): {}",
273                             cacheCandidate.getMojoDescriptor().getFullGoalName());
274                     // Need to populate cached candidate executions for the build cache save result
275                     Mojo mojo = null;
276                     try {
277                         mojo = mavenPluginManager.getConfiguredMojo(Mojo.class, session, cacheCandidate);
278                         MojoExecutionEvent mojoExecutionEvent =
279                                 new MojoExecutionEvent(session, project, cacheCandidate, mojo);
280                         mojoListener.beforeMojoExecution(mojoExecutionEvent);
281                     } catch (PluginConfigurationException | PluginContainerException e) {
282                         throw new RuntimeException(e);
283                     } finally {
284                         if (mojo != null) {
285                             mavenPluginManager.releaseMojo(mojo, cacheCandidate);
286                         }
287                     }
288                 }
289             }
290 
291             // Execute mojos after the cache segment
292             LOGGER.debug("Execute mojos post cache segment");
293             List<MojoExecution> postCachedSegment =
294                     lifecyclePhasesHelper.getPostCachedSegment(project, mojoExecutions, build);
295             for (MojoExecution mojoExecution : postCachedSegment) {
296                 mojoExecutionRunner.run(mojoExecution);
297             }
298             return CacheRestorationStatus.SUCCESS;
299         } finally {
300             mojoExecutionScope.exit();
301         }
302     }
303 
304     private boolean verifyCacheConsistency(
305             MojoExecution cacheCandidate,
306             Build cachedBuild,
307             MavenProject project,
308             MavenSession session,
309             MojoExecutionRunner mojoExecutionRunner,
310             CacheConfig cacheConfig)
311             throws LifecycleExecutionException {
312         long createdTimestamp = System.currentTimeMillis();
313         boolean consistent = true;
314 
315         if (!cacheConfig.getTrackedProperties(cacheCandidate).isEmpty()) {
316             Mojo mojo = null;
317             try {
318                 mojo = mavenPluginManager.getConfiguredMojo(Mojo.class, session, cacheCandidate);
319                 final CompletedExecution completedExecution = cachedBuild.findMojoExecutionInfo(cacheCandidate);
320                 final String fullGoalName = cacheCandidate.getMojoDescriptor().getFullGoalName();
321 
322                 if (completedExecution != null && !isParamsMatched(project, cacheCandidate, mojo, completedExecution)) {
323                     LOGGER.info(
324                             "Mojo cached parameters mismatch with actual, forcing full project build. Mojo: {}",
325                             fullGoalName);
326                     consistent = false;
327                 }
328 
329                 if (consistent) {
330                     long elapsed = System.currentTimeMillis() - createdTimestamp;
331 
332                     LOGGER.debug(
333                             "Plugin execution will be skipped ({} : reconciled in {} millis)", elapsed, fullGoalName);
334                 }
335 
336                 LOGGER.debug(
337                         "Checked {}, resolved mojo: {}, cached params: {}", fullGoalName, mojo, completedExecution);
338 
339             } catch (PluginContainerException | PluginConfigurationException e) {
340                 throw new LifecycleExecutionException("Cannot get configured mojo", e);
341             } finally {
342                 if (mojo != null) {
343                     mavenPluginManager.releaseMojo(mojo, cacheCandidate);
344                 }
345             }
346         } else {
347             LOGGER.debug(
348                     "Plugin execution will be skipped ({} : cached)",
349                     cacheCandidate.getMojoDescriptor().getFullGoalName());
350         }
351 
352         return consistent;
353     }
354 
355     boolean isParamsMatched(
356             MavenProject project, MojoExecution mojoExecution, Mojo mojo, CompletedExecution completedExecution) {
357         List<TrackedProperty> tracked = cacheConfig.getTrackedProperties(mojoExecution);
358 
359         for (TrackedProperty trackedProperty : tracked) {
360             final String propertyName = trackedProperty.getPropertyName();
361 
362             String expectedValue = DtoUtils.findPropertyValue(propertyName, completedExecution);
363             if (expectedValue == null) {
364                 expectedValue = trackedProperty.getDefaultValue() != null ? trackedProperty.getDefaultValue() : "null";
365             }
366 
367             final String currentValue;
368             try {
369                 Object value = ReflectionUtils.getValueIncludingSuperclasses(propertyName, mojo);
370 
371                 if (value instanceof File) {
372                     Path baseDirPath = project.getBasedir().toPath();
373                     Path path = ((File) value).toPath();
374                     currentValue = normalizedPath(path, baseDirPath);
375                 } else if (value instanceof Path) {
376                     Path baseDirPath = project.getBasedir().toPath();
377                     currentValue = normalizedPath(((Path) value), baseDirPath);
378                 } else if (value != null && value.getClass().isArray()) {
379                     currentValue = ArrayUtils.toString(value);
380                 } else {
381                     currentValue = String.valueOf(value);
382                 }
383             } catch (IllegalAccessException e) {
384                 LOGGER.error("Cannot extract plugin property {} from mojo {}", propertyName, mojo, e);
385                 return false;
386             }
387 
388             if (!StringUtils.equals(currentValue, expectedValue)) {
389                 if (!StringUtils.equals(currentValue, trackedProperty.getSkipValue())) {
390                     LOGGER.info(
391                             "Plugin parameter mismatch found. Parameter: {}, expected: {}, actual: {}",
392                             propertyName,
393                             expectedValue,
394                             currentValue);
395                     return false;
396                 } else {
397                     LOGGER.warn(
398                             "Cache contains plugin execution with skip flag and might be incomplete. "
399                                     + "Property: {}, execution {}",
400                             propertyName,
401                             mojoExecutionKey(mojoExecution));
402                 }
403             }
404         }
405         return true;
406     }
407 
408     /**
409      * Best effort to normalize paths from Mojo fields.
410      * - all absolute paths under project root to be relativized for portability
411      * - redundant '..' and '.' to be removed to have consistent views on all paths
412      * - all relative paths are considered portable and should not be touched
413      * - absolute paths outside of project directory could not be deterministically relativized and not touched
414      */
415     private static String normalizedPath(Path path, Path baseDirPath) {
416         boolean isProjectSubdir = path.isAbsolute() && path.startsWith(baseDirPath);
417         if (LOGGER.isDebugEnabled()) {
418             LOGGER.debug(
419                     "normalizedPath isProjectSubdir {} path '{}' - baseDirPath '{}', path.isAbsolute() {}, path.startsWith(baseDirPath) {}",
420                     isProjectSubdir,
421                     path,
422                     baseDirPath,
423                     path.isAbsolute(),
424                     path.startsWith(baseDirPath));
425         }
426         Path preparedPath = isProjectSubdir ? baseDirPath.relativize(path) : path;
427         String normalizedPath = preparedPath.normalize().toString();
428         if (LOGGER.isDebugEnabled()) {
429             LOGGER.debug("normalizedPath '{}' - {} return {}", path, baseDirPath, normalizedPath);
430         }
431         return normalizedPath;
432     }
433 
434     private enum CacheRestorationStatus {
435         SUCCESS,
436         FAILURE,
437         FAILURE_NEEDS_CLEAN;
438     }
439 }