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.Strings;
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 final 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             String projectName = getVersionlessProjectKey(project);
116             List<MojoExecution> cleanPhase = null;
117             if (source == Source.LIFECYCLE && !forkedExecution) {
118                 if (!cacheIsDisabled) {
119                     cacheState = cacheConfig.initialize();
120                     if (cacheState == INITIALIZED) {
121                         // change mojoListener cacheState to INITIALIZED
122                         mojoListener.setCacheState(cacheState);
123                     }
124                     LOGGER.info("Cache is {} on project level for {}", cacheState, projectName);
125                 } else {
126                     LOGGER.info("Cache is explicitly disabled on project level for {}", projectName);
127                 }
128                 cleanPhase = lifecyclePhasesHelper.getCleanSegment(project, mojoExecutions);
129                 for (MojoExecution mojoExecution : cleanPhase) {
130                     mojoExecutionRunner.run(mojoExecution);
131                 }
132                 if (cacheState == INITIALIZED || skipCache) {
133                     result = cacheController.findCachedBuild(session, project, mojoExecutions, skipCache);
134                 }
135             } else {
136                 LOGGER.info("Cache is disabled on project level for {}", projectName);
137             }
138 
139             boolean restorable = result.isSuccess() || result.isPartialSuccess();
140             boolean restored = false; // if partially restored need to save increment
141             if (restorable) {
142                 CacheRestorationStatus cacheRestorationStatus =
143                         restoreProject(result, mojoExecutions, mojoExecutionRunner, cacheConfig);
144                 restored = CacheRestorationStatus.SUCCESS == cacheRestorationStatus;
145                 executeExtraCleanPhaseIfNeeded(cacheRestorationStatus, cleanPhase, mojoExecutionRunner);
146             }
147             if (!restored) {
148                 for (MojoExecution mojoExecution : mojoExecutions) {
149                     if (source == Source.CLI
150                             || mojoExecution.getLifecyclePhase() == null
151                             || lifecyclePhasesHelper.isLaterPhaseThanClean(mojoExecution.getLifecyclePhase())) {
152                         mojoExecutionRunner.run(mojoExecution);
153                     }
154                 }
155             }
156 
157             if (cacheState == INITIALIZED && (!result.isSuccess() || !restored)) {
158                 if (cacheConfig.isSkipSave()) {
159                     LOGGER.info("Cache saving is disabled.");
160                 } else if (cacheConfig.isMandatoryClean()
161                         && lifecyclePhasesHelper
162                                 .getCleanSegment(project, mojoExecutions)
163                                 .isEmpty()) {
164                     LOGGER.info("Cache storing is skipped since there was no \"clean\" phase.");
165                 } else {
166                     final Map<String, MojoExecutionEvent> executionEvents = mojoListener.getProjectExecutions(project);
167                     cacheController.save(result, mojoExecutions, executionEvents);
168                 }
169             }
170 
171             if (cacheConfig.isFailFast() && !result.isSuccess() && !skipCache && !forkedExecution) {
172                 throw new LifecycleExecutionException(
173                         "Failed to restore project[" + projectName + "] from cache, failing build.", project);
174             }
175         } catch (MojoExecutionException e) {
176             throw new LifecycleExecutionException(e.getMessage(), e);
177         }
178     }
179 
180     /**
181      * Cache configuration could demand to restore some files in the project directory (generated sources or even arbitrary content)
182      * If an error occurs during or after this kind of restoration AND a clean phase was required in the build :
183      * we execute an extra clean phase to remove any potential partially restored files
184      *
185      * @param cacheRestorationStatus the restoration status
186      * @param cleanPhase clean phase mojos
187      * @param mojoExecutionRunner mojo runner
188      * @throws LifecycleExecutionException
189      */
190     private void executeExtraCleanPhaseIfNeeded(
191             final CacheRestorationStatus cacheRestorationStatus,
192             List<MojoExecution> cleanPhase,
193             MojoExecutionRunner mojoExecutionRunner)
194             throws LifecycleExecutionException {
195         if (CacheRestorationStatus.FAILURE_NEEDS_CLEAN == cacheRestorationStatus
196                 && cleanPhase != null
197                 && !cleanPhase.isEmpty()) {
198             LOGGER.info("Extra clean phase is executed as cache could be partially restored.");
199             for (MojoExecution mojoExecution : cleanPhase) {
200                 mojoExecutionRunner.run(mojoExecution);
201             }
202         }
203     }
204 
205     private Source getSource(List<MojoExecution> mojoExecutions) {
206         if (mojoExecutions == null || mojoExecutions.isEmpty()) {
207             return null;
208         }
209         for (MojoExecution mojoExecution : mojoExecutions) {
210             if (mojoExecution.getSource() == Source.CLI) {
211                 return Source.CLI;
212             }
213         }
214         return Source.LIFECYCLE;
215     }
216 
217     private CacheRestorationStatus restoreProject(
218             CacheResult cacheResult,
219             List<MojoExecution> mojoExecutions,
220             MojoExecutionRunner mojoExecutionRunner,
221             CacheConfig cacheConfig)
222             throws LifecycleExecutionException, MojoExecutionException {
223 
224         final Build build = cacheResult.getBuildInfo();
225         final MavenProject project = cacheResult.getContext().getProject();
226         final MavenSession session = cacheResult.getContext().getSession();
227         final List<MojoExecution> cachedSegment =
228                 lifecyclePhasesHelper.getCachedSegment(project, mojoExecutions, build);
229 
230         // Verify cache consistency for cached mojos
231         LOGGER.debug("Verify consistency on cached mojos");
232         Set<MojoExecution> forcedExecutionMojos = new HashSet<>();
233         for (MojoExecution cacheCandidate : cachedSegment) {
234             if (cacheController.isForcedExecution(project, cacheCandidate)) {
235                 forcedExecutionMojos.add(cacheCandidate);
236             } else {
237                 if (!verifyCacheConsistency(
238                         cacheCandidate, build, project, session, mojoExecutionRunner, cacheConfig)) {
239                     LOGGER.info("A cached mojo is not consistent, continuing with non cached build");
240                     return CacheRestorationStatus.FAILURE;
241                 }
242             }
243         }
244 
245         // Restore project artifacts
246         ArtifactRestorationReport restorationReport = cacheController.restoreProjectArtifacts(cacheResult);
247         if (!restorationReport.isSuccess()) {
248             LOGGER.info("Cannot restore project artifacts, continuing with non cached build");
249             return restorationReport.isRestoredFilesInProjectDirectory()
250                     ? CacheRestorationStatus.FAILURE_NEEDS_CLEAN
251                     : CacheRestorationStatus.FAILURE;
252         }
253 
254         // Execute mandatory mojos (forced by configuration)
255         LOGGER.debug("Execute mandatory mojos in the cache segment");
256         for (MojoExecution cacheCandidate : cachedSegment) {
257             if (forcedExecutionMojos.contains(cacheCandidate)) {
258                 LOGGER.info(
259                         "Mojo execution is forced by project property: {}",
260                         cacheCandidate.getMojoDescriptor().getFullGoalName());
261                 // need maven 4 as minumum
262                 // mojoExecutionScope.seed(
263                 //        org.apache.maven.api.plugin.Log.class,
264                 //        new DefaultLog(LoggerFactory.getLogger(
265                 //                cacheCandidate.getMojoDescriptor().getFullGoalName())));
266                 // mojoExecutionScope.seed(Project.class, ((DefaultSession)
267                 // session.getSession()).getProject(project));
268                 // mojoExecutionScope.seed(
269                 //        org.apache.maven.api.MojoExecution.class, new DefaultMojoExecution(cacheCandidate));
270                 mojoExecutionRunner.run(cacheCandidate);
271             } else {
272                 LOGGER.info(
273                         "Skipping plugin execution (cached): {}",
274                         cacheCandidate.getMojoDescriptor().getFullGoalName());
275                 // Need to populate cached candidate executions for the build cache save result
276                 Mojo mojo = null;
277                 mojoExecutionScope.enter();
278                 try {
279                     mojoExecutionScope.seed(MavenProject.class, project);
280                     mojoExecutionScope.seed(MojoExecution.class, cacheCandidate);
281 
282                     mojo = mavenPluginManager.getConfiguredMojo(Mojo.class, session, cacheCandidate);
283                     MojoExecutionEvent mojoExecutionEvent =
284                             new MojoExecutionEvent(session, project, cacheCandidate, mojo);
285                     mojoListener.beforeMojoExecution(mojoExecutionEvent);
286                 } catch (PluginConfigurationException | PluginContainerException e) {
287                     throw new RuntimeException(e);
288                 } finally {
289                     mojoExecutionScope.exit();
290                     if (mojo != null) {
291                         mavenPluginManager.releaseMojo(mojo, cacheCandidate);
292                     }
293                 }
294             }
295         }
296 
297         // Execute mojos after the cache segment
298         LOGGER.debug("Execute mojos post cache segment");
299         List<MojoExecution> postCachedSegment =
300                 lifecyclePhasesHelper.getPostCachedSegment(project, mojoExecutions, build);
301         for (MojoExecution mojoExecution : postCachedSegment) {
302             mojoExecutionRunner.run(mojoExecution);
303         }
304         return CacheRestorationStatus.SUCCESS;
305     }
306 
307     private boolean verifyCacheConsistency(
308             MojoExecution cacheCandidate,
309             Build cachedBuild,
310             MavenProject project,
311             MavenSession session,
312             MojoExecutionRunner mojoExecutionRunner,
313             CacheConfig cacheConfig)
314             throws LifecycleExecutionException {
315         long createdTimestamp = System.currentTimeMillis();
316         boolean consistent = true;
317 
318         if (!cacheConfig.getTrackedProperties(cacheCandidate).isEmpty()) {
319             Mojo mojo = null;
320             try {
321                 mojo = mavenPluginManager.getConfiguredMojo(Mojo.class, session, cacheCandidate);
322                 final CompletedExecution completedExecution = cachedBuild.findMojoExecutionInfo(cacheCandidate);
323                 final String fullGoalName = cacheCandidate.getMojoDescriptor().getFullGoalName();
324 
325                 if (completedExecution != null && !isParamsMatched(project, cacheCandidate, mojo, completedExecution)) {
326                     LOGGER.info(
327                             "Mojo cached parameters mismatch with actual, forcing full project build. Mojo: {}",
328                             fullGoalName);
329                     consistent = false;
330                 }
331 
332                 if (consistent) {
333                     long elapsed = System.currentTimeMillis() - createdTimestamp;
334 
335                     LOGGER.debug(
336                             "Plugin execution will be skipped ({} : reconciled in {} millis)", elapsed, fullGoalName);
337                 }
338 
339                 LOGGER.debug(
340                         "Checked {}, resolved mojo: {}, cached params: {}", fullGoalName, mojo, completedExecution);
341 
342             } catch (PluginContainerException | PluginConfigurationException e) {
343                 throw new LifecycleExecutionException("Cannot get configured mojo", e);
344             } finally {
345                 if (mojo != null) {
346                     mavenPluginManager.releaseMojo(mojo, cacheCandidate);
347                 }
348             }
349         } else {
350             LOGGER.debug(
351                     "Plugin execution will be skipped ({} : cached)",
352                     cacheCandidate.getMojoDescriptor().getFullGoalName());
353         }
354 
355         return consistent;
356     }
357 
358     boolean isParamsMatched(
359             MavenProject project, MojoExecution mojoExecution, Mojo mojo, CompletedExecution completedExecution) {
360         List<TrackedProperty> tracked = cacheConfig.getTrackedProperties(mojoExecution);
361 
362         for (TrackedProperty trackedProperty : tracked) {
363             final String propertyName = trackedProperty.getPropertyName();
364 
365             String expectedValue = DtoUtils.findPropertyValue(propertyName, completedExecution);
366             if (expectedValue == null) {
367                 expectedValue = trackedProperty.getDefaultValue() != null ? trackedProperty.getDefaultValue() : "null";
368             }
369 
370             final String currentValue;
371             try {
372                 Object value = ReflectionUtils.getValueIncludingSuperclasses(propertyName, mojo);
373 
374                 if (value instanceof File) {
375                     Path baseDirPath = project.getBasedir().toPath();
376                     Path path = ((File) value).toPath();
377                     currentValue = normalizedPath(path, baseDirPath);
378                 } else if (value instanceof Path) {
379                     Path baseDirPath = project.getBasedir().toPath();
380                     currentValue = normalizedPath(((Path) value), baseDirPath);
381                 } else if (value != null && value.getClass().isArray()) {
382                     currentValue = ArrayUtils.toString(value);
383                 } else {
384                     currentValue = String.valueOf(value);
385                 }
386             } catch (IllegalAccessException e) {
387                 LOGGER.error("Cannot extract plugin property {} from mojo {}", propertyName, mojo, e);
388                 return false;
389             }
390 
391             if (!Strings.CS.equals(currentValue, expectedValue)) {
392                 if (!Strings.CS.equals(currentValue, trackedProperty.getSkipValue())) {
393                     LOGGER.info(
394                             "Plugin parameter mismatch found. Parameter: {}, expected: {}, actual: {}",
395                             propertyName,
396                             expectedValue,
397                             currentValue);
398                     return false;
399                 } else {
400                     LOGGER.warn(
401                             "Cache contains plugin execution with skip flag and might be incomplete. "
402                                     + "Property: {}, execution {}",
403                             propertyName,
404                             mojoExecutionKey(mojoExecution));
405                 }
406             }
407         }
408         return true;
409     }
410 
411     /**
412      * Best effort to normalize paths from Mojo fields.
413      * - all absolute paths under project root to be relativized for portability
414      * - redundant '..' and '.' to be removed to have consistent views on all paths
415      * - all relative paths are considered portable and should not be touched
416      * - absolute paths outside of project directory could not be deterministically relativized and not touched
417      */
418     private static String normalizedPath(Path path, Path baseDirPath) {
419         boolean isProjectSubdir = path.isAbsolute() && path.startsWith(baseDirPath);
420         if (LOGGER.isDebugEnabled()) {
421             LOGGER.debug(
422                     "normalizedPath isProjectSubdir {} path '{}' - baseDirPath '{}', path.isAbsolute() {}, path.startsWith(baseDirPath) {}",
423                     isProjectSubdir,
424                     path,
425                     baseDirPath,
426                     path.isAbsolute(),
427                     path.startsWith(baseDirPath));
428         }
429         Path preparedPath = isProjectSubdir ? baseDirPath.relativize(path) : path;
430         String normalizedPath = preparedPath.normalize().toString();
431         if (LOGGER.isDebugEnabled()) {
432             LOGGER.debug("normalizedPath '{}' - {} return {}", path, baseDirPath, normalizedPath);
433         }
434         return normalizedPath;
435     }
436 
437     private enum CacheRestorationStatus {
438         SUCCESS,
439         FAILURE,
440         FAILURE_NEEDS_CLEAN
441     }
442 }