001/*
002 * Extra Bnd Repository Plugins
003 * Copyright (C) 2019-2021 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package de.mnl.osgi.bnd.maven;
020
021import aQute.bnd.service.RepositoryPlugin;
022import aQute.maven.api.Archive;
023import aQute.maven.api.IMavenRepo;
024import aQute.maven.api.Program;
025import aQute.maven.api.Revision;
026import aQute.maven.provider.MavenBackingRepository;
027import aQute.maven.provider.MavenRepository;
028import aQute.maven.provider.MetadataParser;
029import aQute.maven.provider.MetadataParser.RevisionMetadata;
030import aQute.service.reporter.Reporter;
031import static de.mnl.osgi.bnd.maven.RepositoryUtils.rethrow;
032import static de.mnl.osgi.bnd.maven.RepositoryUtils.unthrow;
033import java.io.Closeable;
034import java.io.File;
035import java.io.IOException;
036import java.lang.reflect.InvocationTargetException;
037import java.lang.reflect.UndeclaredThrowableException;
038import java.util.ArrayList;
039import java.util.Comparator;
040import java.util.List;
041import java.util.Map;
042import java.util.Optional;
043import java.util.concurrent.ConcurrentHashMap;
044import java.util.concurrent.Executor;
045import java.util.regex.Pattern;
046import java.util.stream.Collectors;
047import java.util.stream.Stream;
048import org.apache.maven.model.Model;
049import org.apache.maven.model.building.DefaultModelBuilder;
050import org.apache.maven.model.building.DefaultModelBuildingRequest;
051import org.apache.maven.model.building.DefaultModelProcessor;
052import org.apache.maven.model.building.ModelBuilder;
053import org.apache.maven.model.building.ModelBuildingException;
054import org.apache.maven.model.building.ModelBuildingRequest;
055import org.apache.maven.model.building.ModelBuildingResult;
056import org.apache.maven.model.building.ModelSource;
057import org.apache.maven.model.composition.DefaultDependencyManagementImporter;
058import org.apache.maven.model.inheritance.DefaultInheritanceAssembler;
059import org.apache.maven.model.interpolation.StringSearchModelInterpolator;
060import org.apache.maven.model.io.DefaultModelReader;
061import org.apache.maven.model.locator.DefaultModelLocator;
062import org.apache.maven.model.management.DefaultDependencyManagementInjector;
063import org.apache.maven.model.management.DefaultPluginManagementInjector;
064import org.apache.maven.model.normalization.DefaultModelNormalizer;
065import org.apache.maven.model.path.DefaultModelPathTranslator;
066import org.apache.maven.model.path.DefaultModelUrlNormalizer;
067import org.apache.maven.model.path.DefaultPathTranslator;
068import org.apache.maven.model.path.DefaultUrlNormalizer;
069import org.apache.maven.model.profile.DefaultProfileInjector;
070import org.apache.maven.model.profile.DefaultProfileSelector;
071import org.apache.maven.model.resolution.UnresolvableModelException;
072import org.apache.maven.model.superpom.DefaultSuperPomProvider;
073import org.apache.maven.model.validation.DefaultModelValidator;
074import org.osgi.util.promise.Promise;
075
076/**
077 * Provides a composite {@link IMavenRepo} view on several 
078 * {@link MavenBackingRepository} instances.
079 * The class replaces {@link MavenRepository} which lacks some
080 * required functionality. (Besides, this class has a more
081 * appropriate name.)
082 * <P>
083 * The information about artifacts is provided as a maven
084 * {@link Model}. It is evaluated using the maven libraries and
085 * should therefore be consistent with the model information
086 * used in other maven repository based tools.
087 */
088@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "deprecation",
089    "PMD.ExcessiveImports" })
090public class CompositeMavenRepository implements Closeable {
091
092    public static final Pattern COORDS_SPLITTER = Pattern.compile("\\s*;\\s*");
093    private final MavenRepository bndMavenRepo;
094    private final Reporter reporter;
095    private final Map<Program, List<BoundRevision>> programCache
096        = new ConcurrentHashMap<>();
097    private final Map<Revision, Model> modelCache
098        = new ConcurrentHashMap<>();
099    private final BndModelResolver modelResolver;
100    private final ModelBuilder modelBuilder;
101
102    /**
103     * Use local or remote URL in index.
104     */
105    public enum BinaryLocation {
106        LOCAL, REMOTE
107    }
108
109    /**
110     * Instantiates a new composite maven repository.
111     *
112     * @param base the base
113     * @param repoId the repository id
114     * @param releaseRepos the backing release repositories
115     * @param snapshotRepos the backing snapshot repositories
116     * @param executor an executor
117     * @param reporter the reporter
118     * @throws Exception the exception
119     */
120    @SuppressWarnings({ "PMD.SignatureDeclareThrowsException",
121        "PMD.AvoidDuplicateLiterals", "PMD.NcssCount" })
122    public CompositeMavenRepository(File base, String repoId,
123            List<MavenBackingRepository> releaseRepos,
124            List<MavenBackingRepository> snapshotRepos, Executor executor,
125            Reporter reporter)
126            throws Exception {
127        bndMavenRepo = new MavenRepository(base, repoId, releaseRepos,
128            snapshotRepos, executor, reporter);
129        this.reporter = reporter;
130        modelResolver = new BndModelResolver(bndMavenRepo, reporter);
131
132        // Create the maven model builder. This code is ridiculous,
133        // but using maven's CDI pulls in an even more ridiculous
134        // number of libraries. To get an idea why, read
135        // https://lairdnelson.wordpress.com/2017/03/06/maven-and-the-project-formerly-known-as-aether/
136        DefaultModelBuilder builder = new DefaultModelBuilder();
137        builder.setProfileSelector(new DefaultProfileSelector());
138        DefaultModelProcessor processor = new DefaultModelProcessor();
139        processor.setModelLocator(new DefaultModelLocator());
140        processor.setModelReader(new DefaultModelReader());
141        builder.setModelProcessor(processor);
142        builder.setModelValidator(new DefaultModelValidator());
143        DefaultSuperPomProvider pomProvider = new DefaultSuperPomProvider();
144        pomProvider.setModelProcessor(processor);
145        DefaultSuperPomProvider superPomProvider
146            = new DefaultSuperPomProvider();
147        superPomProvider.setModelProcessor(processor);
148        builder.setSuperPomProvider(superPomProvider);
149        builder.setModelNormalizer(new DefaultModelNormalizer());
150        builder.setInheritanceAssembler(new DefaultInheritanceAssembler());
151        StringSearchModelInterpolator modelInterpolator
152            = new StringSearchModelInterpolator();
153        DefaultPathTranslator pathTranslator = new DefaultPathTranslator();
154        modelInterpolator.setPathTranslator(pathTranslator);
155        DefaultUrlNormalizer urlNormalizer = new DefaultUrlNormalizer();
156        modelInterpolator.setUrlNormalizer(urlNormalizer);
157        builder.setModelInterpolator(modelInterpolator);
158        DefaultModelUrlNormalizer modelUrlNormalizer
159            = new DefaultModelUrlNormalizer();
160        modelUrlNormalizer.setUrlNormalizer(urlNormalizer);
161        builder.setModelUrlNormalizer(modelUrlNormalizer);
162        DefaultModelPathTranslator modelPathTranslator
163            = new DefaultModelPathTranslator();
164        modelPathTranslator.setPathTranslator(pathTranslator);
165        builder.setModelPathTranslator(modelPathTranslator);
166        builder
167            .setPluginManagementInjector(new DefaultPluginManagementInjector());
168        builder.setDependencyManagementInjector(
169            new DefaultDependencyManagementInjector());
170        builder.setDependencyManagementImporter(
171            new DefaultDependencyManagementImporter());
172        builder.setProfileInjector(new DefaultProfileInjector());
173        modelBuilder = builder;
174    }
175
176    /**
177     * Gets the name of this repository.
178     *
179     * @return the name
180     */
181    public String name() {
182        return bndMavenRepo.getName();
183    }
184
185    /**
186     * Reset any cached information.
187     */
188    public void reset() {
189        programCache.clear();
190        modelCache.clear();
191    }
192
193    @Override
194    public void close() throws IOException {
195        bndMavenRepo.close();
196    }
197
198    /**
199    * Returns all backing repositories.
200    *
201    * @return the list of all repositories
202    */
203    public List<MavenBackingRepository> backing() {
204        List<MavenBackingRepository> result
205            = new ArrayList<>(bndMavenRepo.getReleaseRepositories());
206        result.addAll(bndMavenRepo.getSnapshotRepositories());
207        return result;
208    }
209
210    /**
211     * Returns all repositories as a stream.
212     *
213     * @return the repositories as stream
214     */
215    public Stream<MavenBackingRepository> backingAsStream() {
216        return Stream.concat(bndMavenRepo.getReleaseRepositories().stream(),
217            bndMavenRepo.getSnapshotRepositories().stream()).distinct();
218    }
219
220    /**
221     * Retrieves the file from a remote repository into the repositories 
222     * local cache directory if it doesn't exist yet.
223     *
224     * @param archive The archive to fetch
225     * @return the file or null if not found
226     * @throws IOException Signals that an I/O exception has occurred.
227     */
228    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
229        "PMD.AvoidInstanceofChecksInCatchClause",
230        "PMD.AvoidDuplicateLiterals" })
231    public Promise<File> retrieve(Archive archive) throws IOException {
232        try {
233            return bndMavenRepo.get(archive);
234        } catch (Exception e) {
235            if (e instanceof InvocationTargetException
236                && ((InvocationTargetException) e)
237                    .getTargetException() instanceof IOException) {
238                // Should be the only possible exception here
239                throw (IOException) ((InvocationTargetException) e)
240                    .getTargetException();
241            }
242            throw new UndeclaredThrowableException(e);
243        }
244    }
245
246    /**
247     * Get the file object for the archive. The file does not have to exist.
248     * The use case for this is to have the {@link File} already while
249     * waiting for the {@link Promise} returned by {@link #retrieve(Archive)}
250     * to complete.
251     * <P>
252     * This is required for the implementation of {@link RepositoryPlugin#get}.
253     * Besides this use case, it should probably not be used.
254     * 
255     * @param archive the archive to find the file for
256     * @return the File or null if not found
257     */
258    public File toLocalFile(Archive archive) {
259        return bndMavenRepo.toLocalFile(archive);
260    }
261
262    /**
263     * Gets the file from the local cache directory, retrieving it
264     * first if it doesn't exist yet.
265     *
266     * @param archive The archive to fetch
267     * @return the file or null if not found
268     * @throws IOException Signals that an I/O exception has occurred.
269     */
270    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
271        "PMD.AvoidInstanceofChecksInCatchClause",
272        "PMD.AvoidDuplicateLiterals" })
273    public File get(Archive archive) throws IOException {
274        try {
275            return bndMavenRepo.get(archive).getValue();
276        } catch (Exception e) {
277            if (e instanceof InvocationTargetException
278                && ((InvocationTargetException) e)
279                    .getTargetException() instanceof IOException) {
280                // Should be the only possible exception here
281                throw (IOException) ((InvocationTargetException) e)
282                    .getTargetException();
283            }
284            throw new UndeclaredThrowableException(e);
285        }
286    }
287
288    /**
289     * Wrapper for {@link MavenBackingRepository#getRevisions(Program, List)}
290     * that returns the result instead of accumulating it and maps the
291     * (too general) exception.
292     *
293     * @param mbr the backing repository
294     * @param program the program
295     * @return the revisions
296     * @throws IOException Signals that an I/O exception has occurred.
297     */
298    @SuppressWarnings({ "PMD.LinguisticNaming", "PMD.AvoidRethrowingException",
299        "PMD.AvoidCatchingGenericException",
300        "PMD.AvoidThrowingRawExceptionTypes", "PMD.AvoidDuplicateLiterals" })
301    private List<Revision> revisionsFrom(MavenBackingRepository mbr,
302            Program program) {
303        List<Revision> result = new ArrayList<>();
304        try {
305            mbr.getRevisions(program, result);
306        } catch (IOException e) {
307            reporter.exception(e,
308                "Failed to get list of revisions of %s from %s: %s",
309                program, mbr, e.getMessage());
310            return result;
311        } catch (Exception e) {
312            throw new RuntimeException(
313                "Cannot evaluate revisions for " + program + " from " + mbr, e);
314        }
315        return result;
316    }
317
318    /**
319     * Get the bound revisions of the given program.
320     *
321     * @param program the program
322     * @return the list
323     */
324    public Stream<BoundRevision> findRevisions(Program program) {
325        return programCache.computeIfAbsent(program,
326            prg -> backingAsStream()
327                .flatMap(mbr -> revisionsFrom(mbr, program).stream()
328                    .map(revision -> new BoundRevision(mbr, revision)))
329                .collect(Collectors.toList()))
330            .stream();
331    }
332
333    /**
334     * Converts a {@link Revision} to a {@link BoundRevision}.
335     *
336     * @param revision the revision
337     * @return the bound revision
338     */
339    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
340    public Optional<BoundRevision> find(Revision revision) {
341        return findRevisions(revision.program)
342            .filter(rev -> rev.unbound().equals(revision)).findFirst();
343    }
344
345    /**
346     * Converts an {@link Archive} to a {@link BoundArchive}.
347     *
348     * @param archive the archive
349     * @return the bound archive
350     * @throws IOException Signals that an I/O exception has occurred.
351     */
352    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
353    public Optional<BoundArchive> find(Archive archive)
354            throws IOException {
355        return rethrow(IOException.class,
356            () -> find(archive.revision).flatMap(rev -> unthrow(() -> Optional
357                .ofNullable(
358                    resolve(rev, archive.extension, archive.classifier)))));
359    }
360
361    /**
362     * Converts a {@link Program} and a version, which
363     * may be a range, to a {@link BoundRevision}.
364     *
365     * @param program the program
366     * @param version the version
367     * @return the bound revision
368     */
369    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
370    public Optional<BoundRevision> find(Program program,
371            MavenVersionSpecification version) {
372        if (version instanceof MavenVersion) {
373            return find(((MavenVersion) version).of(program));
374        }
375        MavenVersionRange range = (MavenVersionRange) version;
376        return findRevisions(program)
377            .filter(rev -> range.includes(rev.version()))
378            .max(Comparator.naturalOrder());
379    }
380
381    /**
382     * Get a model of the specified revision. Dependency versions
383     * remain unresolved, i.e. when specified as a range, the range
384     * is preserved.
385     *
386     * @param revision the archive
387     * @return the dependencies
388     * @throws MavenResourceException the maven resource exception
389     */
390    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
391        "PMD.AvoidInstanceofChecksInCatchClause", "PMD.PreserveStackTrace",
392        "PMD.AvoidRethrowingException" })
393    public Model model(Revision revision) throws MavenResourceException {
394        return rethrow(MavenResourceException.class,
395            () -> modelCache.computeIfAbsent(
396                revision, key -> unthrow(() -> readModel(key))));
397    }
398
399    private Model readModel(Revision revision) throws MavenResourceException {
400        DefaultModelBuildingRequest request = new DefaultModelBuildingRequest();
401        try {
402            ModelSource modelSource = modelResolver.resolveModel(revision.group,
403                revision.artifact, revision.version.toString());
404            request.setModelResolver(modelResolver)
405                .setModelSource(modelSource)
406                .setValidationLevel(
407                    ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL)
408                .setTwoPhaseBuilding(false);
409            ModelBuildingResult result = modelBuilder.build(request);
410            return result.getEffectiveModel();
411        } catch (UnresolvableModelException | ModelBuildingException e) {
412            throw new MavenResourceException(e);
413        }
414    }
415
416    /**
417     * Gets the resolved archive. "Resolving" an archive means finding
418     * the binaries with the specified extension and classifier belonging
419     * to the given version. While this can be done with straight forward
420     * name mapping for releases, snapshots have a timestamp that has to
421     * be looked up in the backing repository.
422     *
423     * @param revision the revision
424     * @param extension the extension
425     * @param classifier the classifier
426     * @return the resolved archive
427     * @throws IOException Signals that an I/O exception has occurred.
428     */
429    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
430        "PMD.AvoidThrowingRawExceptionTypes", "PMD.AvoidRethrowingException" })
431    public BoundArchive resolve(BoundRevision revision,
432            String extension, String classifier) throws IOException {
433        if (!revision.isSnapshot()) {
434            return revision.archive(extension, classifier);
435        }
436        try {
437            MavenVersion version = MavenVersion.from(revision
438                .mavenBackingRepository().getVersion(revision.unbound()));
439            return revision.archive(version, extension, classifier);
440        } catch (IOException e) {
441            throw e;
442        } catch (Exception e) {
443            throw new RuntimeException(e);
444        }
445    }
446
447    /**
448     * Refresh a snapshot.
449     *
450     * @param archive the archive
451     */
452    @SuppressWarnings("PMD.AvoidCatchingGenericException")
453    protected void refreshSnapshot(BoundArchive archive) {
454        File metaFile = bndMavenRepo.toLocalFile(
455            archive.getRevision()
456                .metadata(archive.mavenBackingRepository().getId()));
457        RevisionMetadata metaData;
458        try {
459            metaData = MetadataParser.parseRevisionMetadata(metaFile);
460        } catch (Exception e) {
461            reporter.exception(e, "Problem accessing %s.", archive);
462            return;
463        }
464        File archiveFile = bndMavenRepo.toLocalFile(archive);
465        if (archiveFile.lastModified() < metaData.lastUpdated) {
466            archiveFile.delete();
467        }
468        File pomFile = bndMavenRepo.toLocalFile(archive.getPomArchive());
469        if (pomFile.lastModified() < metaData.lastUpdated) {
470            pomFile.delete();
471        }
472    }
473
474}