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