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.io.IOException;
27  import java.nio.file.Path;
28  import java.util.HashSet;
29  import java.util.List;
30  import java.util.Map;
31  import java.util.Set;
32  
33  import org.apache.commons.lang3.ArrayUtils;
34  import org.apache.commons.lang3.Strings;
35  import org.apache.maven.SessionScoped;
36  import org.apache.maven.buildcache.artifact.ArtifactRestorationReport;
37  import org.apache.maven.buildcache.checksum.MavenProjectInput;
38  import org.apache.maven.buildcache.xml.Build;
39  import org.apache.maven.buildcache.xml.CacheConfig;
40  import org.apache.maven.buildcache.xml.CacheState;
41  import org.apache.maven.buildcache.xml.DtoUtils;
42  import org.apache.maven.buildcache.xml.build.CompletedExecution;
43  import org.apache.maven.buildcache.xml.config.TrackedProperty;
44  import org.apache.maven.execution.MavenSession;
45  import org.apache.maven.execution.MojoExecutionEvent;
46  import org.apache.maven.execution.scope.internal.MojoExecutionScope;
47  import org.apache.maven.lifecycle.LifecycleExecutionException;
48  import org.apache.maven.plugin.MavenPluginManager;
49  import org.apache.maven.plugin.Mojo;
50  import org.apache.maven.plugin.MojoExecution;
51  import org.apache.maven.plugin.MojoExecution.Source;
52  import org.apache.maven.plugin.MojoExecutionException;
53  import org.apache.maven.plugin.MojoExecutionRunner;
54  import org.apache.maven.plugin.MojosExecutionStrategy;
55  import org.apache.maven.plugin.PluginConfigurationException;
56  import org.apache.maven.plugin.PluginContainerException;
57  import org.apache.maven.project.MavenProject;
58  import org.codehaus.plexus.util.ReflectionUtils;
59  import org.slf4j.Logger;
60  import org.slf4j.LoggerFactory;
61  
62  import static org.apache.maven.buildcache.CacheUtils.mojoExecutionKey;
63  import static org.apache.maven.buildcache.checksum.KeyUtils.getVersionlessProjectKey;
64  import static org.apache.maven.buildcache.xml.CacheState.DISABLED;
65  import static org.apache.maven.buildcache.xml.CacheState.INITIALIZED;
66  
67  /**
68   * Build cache-enabled version of the {@link MojosExecutionStrategy}.
69   */
70  @SessionScoped
71  @Named
72  @Priority(10)
73  @SuppressWarnings("unused")
74  public class BuildCacheMojosExecutionStrategy implements MojosExecutionStrategy {
75  
76      private static final Logger LOGGER = LoggerFactory.getLogger(BuildCacheMojosExecutionStrategy.class);
77  
78      private final CacheController cacheController;
79      private final CacheConfig cacheConfig;
80      private final MojoParametersListener mojoListener;
81      private final LifecyclePhasesHelper lifecyclePhasesHelper;
82      private final MavenPluginManager mavenPluginManager;
83      private final MojoExecutionScope mojoExecutionScope;
84  
85      @Inject
86      public BuildCacheMojosExecutionStrategy(
87              CacheController cacheController,
88              CacheConfig cacheConfig,
89              MojoParametersListener mojoListener,
90              LifecyclePhasesHelper lifecyclePhasesHelper,
91              MavenPluginManager mavenPluginManager,
92              MojoExecutionScope mojoExecutionScope) {
93          this.cacheController = cacheController;
94          this.cacheConfig = cacheConfig;
95          this.mojoListener = mojoListener;
96          this.lifecyclePhasesHelper = lifecyclePhasesHelper;
97          this.mavenPluginManager = mavenPluginManager;
98          this.mojoExecutionScope = mojoExecutionScope;
99      }
100 
101     public void execute(
102             List<MojoExecution> mojoExecutions, MavenSession session, MojoExecutionRunner mojoExecutionRunner)
103             throws LifecycleExecutionException {
104 
105         try {
106             final MavenProject project = session.getCurrentProject();
107             final Source source = getSource(mojoExecutions);
108 
109             // execute clean bound goals before restoring to not interfere/slowdown clean
110             CacheState cacheState = DISABLED;
111             CacheResult result = CacheResult.empty();
112             boolean skipCache =
113                     cacheConfig.isSkipCache() || MavenProjectInput.isSkipCache(project) || isGoalClean(mojoExecutions);
114             boolean cacheIsDisabled = MavenProjectInput.isCacheDisabled(project);
115             // Forked execution should be thought as a part of originating mojo internal
116             // implementation
117             // If forkedExecution is detected, it means that originating mojo is not cached
118             // so forks should rerun too
119             boolean forkedExecution = lifecyclePhasesHelper.isForkedProject(project);
120             String projectName = getVersionlessProjectKey(project);
121             List<MojoExecution> cleanPhase = null;
122             if (source == Source.LIFECYCLE && !forkedExecution) {
123                 if (!cacheIsDisabled) {
124                     cacheState = cacheConfig.initialize();
125                     if (cacheState == INITIALIZED) {
126                         // change mojoListener cacheState to INITIALIZED
127                         mojoListener.setCacheState(cacheState);
128                     }
129                     LOGGER.info("Cache is {} on project level for {}", cacheState, projectName);
130                 } else {
131                     LOGGER.info("Cache is explicitly disabled on project level for {}", projectName);
132                 }
133                 cleanPhase = lifecyclePhasesHelper.getCleanSegment(project, mojoExecutions);
134                 for (MojoExecution mojoExecution : cleanPhase) {
135                     mojoExecutionRunner.run(mojoExecution);
136                 }
137                 if (cacheState == INITIALIZED) {
138                     result = cacheController.findCachedBuild(session, project, mojoExecutions, skipCache);
139                 }
140             } else {
141                 LOGGER.info("Cache is disabled on project level for {}", projectName);
142             }
143 
144             boolean restorable = result.isSuccess() || result.isPartialSuccess();
145             boolean restored = false; // if partially restored need to save increment
146 
147             if (restorable) {
148                 CacheRestorationStatus cacheRestorationStatus =
149                         restoreProject(result, mojoExecutions, mojoExecutionRunner, cacheConfig);
150                 restored = CacheRestorationStatus.SUCCESS == cacheRestorationStatus;
151                 executeExtraCleanPhaseIfNeeded(cacheRestorationStatus, cleanPhase, mojoExecutionRunner);
152             }
153 
154             try {
155                 if (cacheState == INITIALIZED && !restored && !forkedExecution) {
156                     // Move pre-existing artifacts to staging directory to prevent caching stale files
157                     // from previous builds (e.g., after source changes or from cache restored
158                     // with clock skew). This ensures save() only sees fresh files built during this session.
159                     // Skip for forked executions since they don't cache and shouldn't modify artifacts.
160                     // Skip when cache is disabled to avoid accessing uninitialized cache configuration.
161                     try {
162                         cacheController.stagePreExistingArtifacts(session, project);
163                     } catch (IOException e) {
164                         LOGGER.debug("Failed to stage pre-existing artifacts: {}", e.getMessage());
165                         // Continue build - if staging fails, we'll just cache what exists
166                     }
167                 }
168 
169                 if (!restored) {
170                     for (MojoExecution mojoExecution : mojoExecutions) {
171                         if (source == Source.CLI
172                                 || mojoExecution.getLifecyclePhase() == null
173                                 || lifecyclePhasesHelper.isLaterPhaseThanClean(mojoExecution.getLifecyclePhase())) {
174                             mojoExecutionRunner.run(mojoExecution);
175                         }
176                     }
177                 }
178 
179                 if (cacheState == INITIALIZED && (!result.isSuccess() || !restored)) {
180                     if (cacheConfig.isSkipSave()) {
181                         LOGGER.debug("Cache saving is disabled.");
182                     } else if (cacheConfig.isMandatoryClean()
183                             && lifecyclePhasesHelper
184                                     .getCleanSegment(project, mojoExecutions)
185                                     .isEmpty()) {
186                         LOGGER.debug("Cache storing is skipped since there was no \"clean\" phase.");
187                     } else {
188                         final Map<String, MojoExecutionEvent> executionEvents =
189                                 mojoListener.getProjectExecutions(project);
190                         cacheController.save(result, mojoExecutions, executionEvents);
191                     }
192                 }
193             } finally {
194                 // Always restore staged files after build completes (whether save ran or not).
195                 // Files that were rebuilt are discarded; files that weren't rebuilt are restored.
196                 // Skip for forked executions since they don't stage artifacts.
197                 // Skip when cache is disabled since staging was not performed.
198                 if (cacheState == INITIALIZED && !restored && !forkedExecution) {
199                     cacheController.restoreStagedArtifacts(session, project);
200                 }
201             }
202 
203             if (cacheConfig.isFailFast() && !result.isSuccess() && !skipCache && !forkedExecution) {
204                 throw new LifecycleExecutionException(
205                         "Failed to restore project[" + projectName + "] from cache, failing build.", project);
206             }
207         } catch (MojoExecutionException e) {
208             throw new LifecycleExecutionException(e.getMessage(), e);
209         }
210     }
211 
212     /**
213      * Check if the current mojo execution is for the clean goal
214      *
215      * @param mojoExecutions the mojo executions
216      * @return true if the goal is clean and it is the only goal, false otherwise
217      */
218     private boolean isGoalClean(List<MojoExecution> mojoExecutions) {
219         if (mojoExecutions.stream().allMatch(mojoExecution -> "clean".equals(mojoExecution.getLifecyclePhase()))) {
220             LOGGER.info("Build cache is disabled for 'clean' goal.");
221             return true;
222         }
223         return false;
224     }
225 
226     /**
227      * Cache configuration could demand to restore some files in the project
228      * directory (generated sources or even arbitrary content)
229      * If an error occurs during or after this kind of restoration AND a clean phase
230      * was required in the build, we execute an extra clean phase to remove any
231      * potential partially restored files.
232      *
233      * @param cacheRestorationStatus the restoration status
234      * @param cleanPhase             clean phase mojos
235      * @param mojoExecutionRunner    mojo runner
236      * @throws LifecycleExecutionException
237      */
238     private void executeExtraCleanPhaseIfNeeded(
239             final CacheRestorationStatus cacheRestorationStatus,
240             List<MojoExecution> cleanPhase,
241             MojoExecutionRunner mojoExecutionRunner)
242             throws LifecycleExecutionException {
243         if (CacheRestorationStatus.FAILURE_NEEDS_CLEAN == cacheRestorationStatus
244                 && cleanPhase != null
245                 && !cleanPhase.isEmpty()) {
246             LOGGER.info("Extra clean phase is executed as cache could be partially restored.");
247             for (MojoExecution mojoExecution : cleanPhase) {
248                 mojoExecutionRunner.run(mojoExecution);
249             }
250         }
251     }
252 
253     private Source getSource(List<MojoExecution> mojoExecutions) {
254         if (mojoExecutions == null || mojoExecutions.isEmpty()) {
255             return null;
256         }
257         for (MojoExecution mojoExecution : mojoExecutions) {
258             if (mojoExecution.getSource() == Source.CLI) {
259                 return Source.CLI;
260             }
261         }
262         return Source.LIFECYCLE;
263     }
264 
265     private CacheRestorationStatus restoreProject(
266             CacheResult cacheResult,
267             List<MojoExecution> mojoExecutions,
268             MojoExecutionRunner mojoExecutionRunner,
269             CacheConfig cacheConfig)
270             throws LifecycleExecutionException, MojoExecutionException {
271 
272         final Build build = cacheResult.getBuildInfo();
273         final MavenProject project = cacheResult.getContext().getProject();
274         final MavenSession session = cacheResult.getContext().getSession();
275         final List<MojoExecution> cachedSegment =
276                 lifecyclePhasesHelper.getCachedSegment(project, mojoExecutions, build);
277 
278         // Verify cache consistency for cached mojos
279         LOGGER.debug("Verify consistency on cached mojos");
280         Set<MojoExecution> forcedExecutionMojos = new HashSet<>();
281         for (MojoExecution cacheCandidate : cachedSegment) {
282             if (cacheController.isForcedExecution(project, cacheCandidate)) {
283                 forcedExecutionMojos.add(cacheCandidate);
284             } else {
285                 if (!verifyCacheConsistency(
286                         cacheCandidate, build, project, session, mojoExecutionRunner, cacheConfig)) {
287                     LOGGER.info("A cached mojo is not consistent, continuing with non cached build");
288                     return CacheRestorationStatus.FAILURE;
289                 }
290             }
291         }
292 
293         // Restore project artifacts
294         ArtifactRestorationReport restorationReport = cacheController.restoreProjectArtifacts(cacheResult);
295         if (!restorationReport.isSuccess()) {
296             LOGGER.info("Cannot restore project artifacts, continuing with non cached build");
297             return restorationReport.isRestoredFilesInProjectDirectory()
298                     ? CacheRestorationStatus.FAILURE_NEEDS_CLEAN
299                     : CacheRestorationStatus.FAILURE;
300         }
301 
302         // Execute mandatory mojos (forced by configuration)
303         LOGGER.debug("Execute mandatory mojos in the cache segment");
304         for (MojoExecution cacheCandidate : cachedSegment) {
305             if (forcedExecutionMojos.contains(cacheCandidate)) {
306                 LOGGER.info(
307                         "Mojo execution is forced by project property: {}",
308                         cacheCandidate.getMojoDescriptor().getFullGoalName());
309                 mojoExecutionRunner.run(cacheCandidate);
310             } else {
311                 LOGGER.info(
312                         "Skipping plugin execution (cached): {}",
313                         cacheCandidate.getMojoDescriptor().getFullGoalName());
314                 // Need to populate cached candidate executions for the build cache save result
315                 Mojo mojo = null;
316                 mojoExecutionScope.enter();
317                 try {
318                     mojoExecutionScope.seed(MavenProject.class, project);
319                     mojoExecutionScope.seed(MojoExecution.class, cacheCandidate);
320 
321                     mojo = mavenPluginManager.getConfiguredMojo(Mojo.class, session, cacheCandidate);
322                     MojoExecutionEvent mojoExecutionEvent =
323                             new MojoExecutionEvent(session, project, cacheCandidate, mojo);
324                     mojoListener.beforeMojoExecution(mojoExecutionEvent);
325                 } catch (PluginConfigurationException | PluginContainerException e) {
326                     throw new RuntimeException(e);
327                 } finally {
328                     mojoExecutionScope.exit();
329                     if (mojo != null) {
330                         mavenPluginManager.releaseMojo(mojo, cacheCandidate);
331                     }
332                 }
333             }
334         }
335 
336         // Execute mojos after the cache segment
337         LOGGER.debug("Execute mojos post cache segment");
338         List<MojoExecution> postCachedSegment =
339                 lifecyclePhasesHelper.getPostCachedSegment(project, mojoExecutions, build);
340         for (MojoExecution mojoExecution : postCachedSegment) {
341             mojoExecutionRunner.run(mojoExecution);
342         }
343         return CacheRestorationStatus.SUCCESS;
344     }
345 
346     private boolean verifyCacheConsistency(
347             MojoExecution cacheCandidate,
348             Build cachedBuild,
349             MavenProject project,
350             MavenSession session,
351             MojoExecutionRunner mojoExecutionRunner,
352             CacheConfig cacheConfig)
353             throws LifecycleExecutionException {
354         long createdTimestamp = System.currentTimeMillis();
355         boolean consistent = true;
356 
357         if (!cacheConfig.getTrackedProperties(cacheCandidate).isEmpty()) {
358             Mojo mojo = null;
359             try {
360                 mojo = mavenPluginManager.getConfiguredMojo(Mojo.class, session, cacheCandidate);
361                 final CompletedExecution completedExecution = cachedBuild.findMojoExecutionInfo(cacheCandidate);
362                 final String fullGoalName = cacheCandidate.getMojoDescriptor().getFullGoalName();
363 
364                 if (completedExecution != null && !isParamsMatched(project, cacheCandidate, mojo, completedExecution)) {
365                     LOGGER.info(
366                             "Mojo cached parameters mismatch with actual, forcing full project build. Mojo: {}",
367                             fullGoalName);
368                     consistent = false;
369                 }
370 
371                 if (consistent) {
372                     long elapsed = System.currentTimeMillis() - createdTimestamp;
373 
374                     LOGGER.debug(
375                             "Plugin execution will be skipped ({} : reconciled in {} millis)", elapsed, fullGoalName);
376                 }
377 
378                 LOGGER.debug(
379                         "Checked {}, resolved mojo: {}, cached params: {}", fullGoalName, mojo, completedExecution);
380 
381             } catch (PluginContainerException | PluginConfigurationException e) {
382                 throw new LifecycleExecutionException("Cannot get configured mojo", e);
383             } finally {
384                 if (mojo != null) {
385                     mavenPluginManager.releaseMojo(mojo, cacheCandidate);
386                 }
387             }
388         } else {
389             LOGGER.debug(
390                     "Plugin execution will be skipped ({} : cached)",
391                     cacheCandidate.getMojoDescriptor().getFullGoalName());
392         }
393 
394         return consistent;
395     }
396 
397     boolean isParamsMatched(
398             MavenProject project, MojoExecution mojoExecution, Mojo mojo, CompletedExecution completedExecution) {
399         List<TrackedProperty> tracked = cacheConfig.getTrackedProperties(mojoExecution);
400 
401         for (TrackedProperty trackedProperty : tracked) {
402             final String propertyName = trackedProperty.getPropertyName();
403 
404             String expectedValue = DtoUtils.findPropertyValue(propertyName, completedExecution);
405             if (expectedValue == null) {
406                 expectedValue = trackedProperty.getDefaultValue() != null ? trackedProperty.getDefaultValue() : "null";
407             }
408 
409             final String currentValue;
410             try {
411                 Object value = ReflectionUtils.getValueIncludingSuperclasses(propertyName, mojo);
412 
413                 if (value instanceof File) {
414                     Path baseDirPath = project.getBasedir().toPath();
415                     Path path = ((File) value).toPath();
416                     currentValue = normalizedPath(path, baseDirPath);
417                 } else if (value instanceof Path) {
418                     Path baseDirPath = project.getBasedir().toPath();
419                     currentValue = normalizedPath(((Path) value), baseDirPath);
420                 } else if (value != null && value.getClass().isArray()) {
421                     currentValue = ArrayUtils.toString(value);
422                 } else {
423                     currentValue = String.valueOf(value);
424                 }
425             } catch (IllegalAccessException e) {
426                 LOGGER.error("Cannot extract plugin property {} from mojo {}", propertyName, mojo, e);
427                 return false;
428             }
429 
430             if (!Strings.CS.equals(currentValue, expectedValue)) {
431                 if (!Strings.CS.equals(currentValue, trackedProperty.getSkipValue())) {
432                     LOGGER.info(
433                             "Plugin parameter mismatch found. Parameter: {}, expected: {}, actual: {}",
434                             propertyName,
435                             expectedValue,
436                             currentValue);
437                     return false;
438                 } else {
439                     LOGGER.warn(
440                             "Cache contains plugin execution with skip flag and might be incomplete. "
441                                     + "Property: {}, execution {}",
442                             propertyName,
443                             mojoExecutionKey(mojoExecution));
444                 }
445             }
446         }
447         return true;
448     }
449 
450     /**
451      * Best effort to normalize paths from Mojo fields.
452      * - all absolute paths under project root to be relativized for portability
453      * - redundant '..' and '.' to be removed to have consistent views on all paths
454      * - all relative paths are considered portable and should not be touched
455      * - absolute paths outside of project directory could not be deterministically
456      * relativized and not touched
457      */
458     private static String normalizedPath(Path path, Path baseDirPath) {
459         boolean isProjectSubdir = path.isAbsolute() && path.startsWith(baseDirPath);
460         if (LOGGER.isDebugEnabled()) {
461             LOGGER.debug(
462                     "normalizedPath isProjectSubdir {} path '{}' - baseDirPath '{}', path.isAbsolute() {}, path.startsWith(baseDirPath) {}",
463                     isProjectSubdir,
464                     path,
465                     baseDirPath,
466                     path.isAbsolute(),
467                     path.startsWith(baseDirPath));
468         }
469         Path preparedPath = isProjectSubdir ? baseDirPath.relativize(path) : path;
470         String normalizedPath = preparedPath.normalize().toString();
471         if (LOGGER.isDebugEnabled()) {
472             LOGGER.debug("normalizedPath '{}' - {} return {}", path, baseDirPath, normalizedPath);
473         }
474         return normalizedPath;
475     }
476 
477     private enum CacheRestorationStatus {
478         SUCCESS,
479         FAILURE,
480         FAILURE_NEEDS_CLEAN
481     }
482 }