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.repository.maven.idxmvn;
020
021import aQute.bnd.http.HttpClient;
022import aQute.bnd.osgi.Processor;
023import aQute.bnd.osgi.repository.ResourcesRepository;
024import aQute.maven.api.Archive;
025import aQute.maven.provider.MavenBackingRepository;
026import aQute.service.reporter.Reporter;
027import de.mnl.osgi.bnd.maven.MavenResourceRepository;
028import static de.mnl.osgi.bnd.maven.RepositoryUtils.rethrow;
029import static de.mnl.osgi.bnd.maven.RepositoryUtils.unthrow;
030import java.io.File;
031import java.io.IOException;
032import java.io.OutputStream;
033import java.net.URL;
034import java.nio.file.Files;
035import java.nio.file.Path;
036import java.util.ArrayList;
037import java.util.Arrays;
038import java.util.Collections;
039import java.util.Comparator;
040import java.util.HashMap;
041import java.util.Iterator;
042import java.util.List;
043import java.util.Map;
044import java.util.Optional;
045import java.util.concurrent.CompletableFuture;
046import java.util.concurrent.CompletionException;
047import java.util.concurrent.ConcurrentHashMap;
048import java.util.concurrent.ExecutorService;
049import java.util.concurrent.Executors;
050import java.util.concurrent.atomic.AtomicBoolean;
051import javax.xml.parsers.DocumentBuilderFactory;
052import javax.xml.parsers.ParserConfigurationException;
053import javax.xml.transform.OutputKeys;
054import javax.xml.transform.Transformer;
055import javax.xml.transform.TransformerException;
056import javax.xml.transform.TransformerFactory;
057import javax.xml.transform.dom.DOMSource;
058import javax.xml.transform.stream.StreamResult;
059import org.osgi.resource.Resource;
060import org.osgi.service.repository.Repository;
061import org.slf4j.Logger;
062import org.slf4j.LoggerFactory;
063import org.w3c.dom.Document;
064import org.w3c.dom.Element;
065
066/**
067 * Provide an OSGi repository (a collection of {@link Resource}s, see 
068 * {@link Repository}), filled with resolved artifacts from given groupIds.
069 */
070@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyFields",
071    "PMD.FieldNamingConventions" })
072public class IndexedMavenRepository extends ResourcesRepository {
073
074    /* default */ static final ExecutorService programLoaders
075        = Executors.newFixedThreadPool(4);
076    /* default */ static final ExecutorService revisionLoaders
077        = Executors.newFixedThreadPool(8);
078
079    private static final Logger LOG = LoggerFactory.getLogger(
080        IndexedMavenRepository.class);
081    private final String name;
082    private final Path indexDbDir;
083    private final Path depsDir;
084    private final List<URL> releaseUrls;
085    private final List<URL> snapshotUrls;
086    private final File localRepo;
087    private final Reporter reporter;
088    private final HttpClient client;
089    private final boolean logIndexing;
090    private final ExecutorService groupLoaders
091        = Executors.newFixedThreadPool(4);
092    private final MavenResourceRepository mavenRepository;
093    private final Map<String, MavenGroupRepository> groups
094        = new ConcurrentHashMap<>();
095    private Map<String, MavenGroupRepository> backupGroups;
096    private final AtomicBoolean refreshing = new AtomicBoolean();
097
098    /**
099     * Create a new instance that uses the provided information/resources to perform
100     * its work.
101     *
102     * @param name the name
103     * @param releaseUrls the release urls
104     * @param snapshotUrls the snapshot urls
105     * @param localRepo the local Maven repository (cache)
106     * @param indexDbDir the persistent representation of this repository's content
107     * @param reporter a reporter for reporting the progress
108     * @param client an HTTP client for obtaining information from the Nexus server
109     * @param logIndexing the log indexing
110     * @throws Exception the exception
111     */
112    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
113        "PMD.SignatureDeclareThrowsException", "PMD.AvoidDuplicateLiterals" })
114    public IndexedMavenRepository(String name, List<URL> releaseUrls,
115            List<URL> snapshotUrls, File localRepo, File indexDbDir,
116            Reporter reporter, HttpClient client, boolean logIndexing)
117            throws Exception {
118        this.name = name;
119        this.indexDbDir = indexDbDir.toPath();
120        depsDir = this.indexDbDir.resolve("dependencies");
121        this.releaseUrls = releaseUrls;
122        this.snapshotUrls = snapshotUrls;
123        this.localRepo = localRepo;
124        this.reporter = reporter;
125        this.client = client;
126        this.logIndexing = logIndexing;
127
128        // Check prerequisites
129        if (indexDbDir.exists() && !indexDbDir.isDirectory()) {
130            reporter.error("%s must be a directory.", indexDbDir);
131            throw new IOException(indexDbDir + "must be a directory.");
132        }
133        if (!indexDbDir.exists()) {
134            indexDbDir.mkdirs();
135        }
136        if (!depsDir.toFile().exists()) {
137            depsDir.toFile().mkdir();
138        }
139
140        // Our repository
141        mavenRepository = createMavenRepository();
142
143        // Restore
144        @SuppressWarnings("PMD.UseConcurrentHashMap")
145        Map<String, MavenGroupRepository> oldGroups = new HashMap<>();
146        CompletableFuture
147            .allOf(scanRequested(oldGroups), scanDependencies(oldGroups)).get();
148        for (MavenGroupRepository groupRepo : groups.values()) {
149            addAll(groupRepo.getResources());
150        }
151        backupGroups = groups;
152    }
153
154    @SuppressWarnings({ "PMD.SignatureDeclareThrowsException", "resource" })
155    private MavenResourceRepository createMavenRepository() throws Exception {
156        // Create repository from URLs
157        List<MavenBackingRepository> releaseBackers = new ArrayList<>();
158        for (URL url : releaseUrls) {
159            releaseBackers.addAll(MavenBackingRepository.create(
160                url.toString(), reporter, localRepo, client));
161        }
162        List<MavenBackingRepository> snapshotBackers = new ArrayList<>();
163        for (URL url : snapshotUrls) {
164            snapshotBackers.addAll(MavenBackingRepository.create(
165                url.toString(), reporter, localRepo, client));
166        }
167        return new MavenResourceRepository(
168            localRepo, name(), releaseBackers,
169            snapshotBackers, Processor.getExecutor(), reporter)
170                .setResourceSupplier(this::restoreResource);
171    }
172
173    private Optional<Resource> restoreResource(Archive archive) {
174        return Optional.ofNullable(backupGroups.get(archive.revision.group))
175            .flatMap(group -> group.searchInBackup(archive));
176    }
177
178    /**
179     * Return the name of this repository.
180     * 
181     * @return the name;
182     */
183    public final String name() {
184        return name;
185    }
186
187    /**
188     * Return the representation of this repository in the local file system.
189     * 
190     * @return the location
191     */
192    public final File location() {
193        return indexDbDir.toFile();
194    }
195
196    /**
197     * Returns true if indexing is to be logged.
198     *
199     * @return true, if indexing should be log
200     */
201    public boolean logIndexing() {
202        return logIndexing;
203    }
204
205    /**
206     * Return the Maven repository object used to implements this repository.
207     *
208     * @return the repository 
209     */
210    public MavenResourceRepository mavenRepository() {
211        return mavenRepository;
212    }
213
214    /**
215     * Get or create the group repository for the given group id.
216     * If the repository is created, it is created as a repository
217     * for dependencies.
218     *
219     * @param groupId the group id
220     * @return the repository
221     * @throws IOException Signals that an I/O exception has occurred.
222     */
223    public MavenGroupRepository getOrCreateGroupRepository(String groupId)
224            throws IOException {
225        @SuppressWarnings("PMD.PrematureDeclaration")
226        MavenGroupRepository result = rethrow(IOException.class,
227            () -> groups.computeIfAbsent(groupId,
228                grp -> unthrow(() -> new MavenGroupRepository(grp,
229                    depsDir.resolve(grp), false, this, client, reporter))));
230        return result;
231    }
232
233    /**
234     * Refresh this repository's content.
235     * 
236     * @return true if refreshed, false if not refreshed possibly due to error
237     * @throws Exception if a problem occurs
238     */
239    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
240    public boolean refresh() throws Exception {
241        if (!refreshing.compareAndSet(false, true)) {
242            reporter.warning("Repository is already refreshing.");
243            return false;
244        }
245        String threadName = Thread.currentThread().getName();
246        try {
247            Thread.currentThread().setName("IndexedMaven Refresher");
248            return doRefresh();
249        } finally {
250            Thread.currentThread().setName(threadName);
251            refreshing.set(false);
252        }
253    }
254
255    @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops",
256        "PMD.AvoidDuplicateLiterals", "PMD.SignatureDeclareThrowsException",
257        "PMD.CognitiveComplexity", "PMD.NPathComplexity" })
258    private boolean doRefresh() throws Exception {
259        mavenRepository.reset();
260
261        // Reuse and clear (or create new) group repositories for the existing
262        // directories, first for explicitly requested group ids...
263        backupGroups = new HashMap<>(groups);
264        groups.clear();
265        for (var group : backupGroups.values()) {
266            group.reset();
267        }
268
269        // Scan requested first to avoid problems if a group is moved from
270        // dependency to requested.
271        scanRequested(backupGroups).get();
272        scanDependencies(backupGroups).get();
273
274        // Refresh them all.
275        @SuppressWarnings("PMD.GuardLogStatement")
276        CompletableFuture<?>[] repoLoaders
277            = new ArrayList<>(groups.values()).stream()
278                .map(repo -> CompletableFuture.runAsync(() -> {
279                    String threadName = Thread.currentThread().getName();
280                    try {
281                        Thread.currentThread()
282                            .setName("RepoLoader " + repo.id());
283                        LOG.debug("Reloading %s...", repo.id());
284                        repo.reload();
285                    } catch (IOException e) {
286                        throw new CompletionException(e);
287                    } finally {
288                        Thread.currentThread().setName(threadName);
289                    }
290                }, groupLoaders))
291                .toArray(CompletableFuture[]::new);
292        CompletableFuture.allOf(repoLoaders).get();
293        // Remove no longer required group repositories.
294        for (Iterator<Map.Entry<String, MavenGroupRepository>> iter
295            = groups.entrySet().iterator(); iter.hasNext();) {
296            Map.Entry<String, MavenGroupRepository> entry = iter.next();
297            if (entry.getValue().removeIfRedundant()) {
298                iter.remove();
299            }
300        }
301        CompletableFuture.allOf(
302            // Persist updated data.
303            CompletableFuture.allOf(new ArrayList<>(groups.values()).stream()
304                .map(repo -> CompletableFuture.runAsync(() -> {
305                    try {
306                        repo.flush();
307                    } catch (IOException e) {
308                        throw new CompletionException(e);
309                    }
310                }, groupLoaders))
311                .toArray(CompletableFuture[]::new)),
312            // Write federated index.
313            CompletableFuture.runAsync(() -> {
314                if (groups.keySet().equals(backupGroups.keySet())) {
315                    return;
316                }
317                try (OutputStream fos
318                    = Files.newOutputStream(indexDbDir.resolve("index.xml"))) {
319                    writeFederatedIndex(fos);
320                } catch (IOException e) {
321                    throw new CompletionException(e);
322                }
323            }, groupLoaders),
324            // Add collected to root (this).
325            CompletableFuture.runAsync(() -> {
326                set(Collections.emptyList());
327                for (MavenGroupRepository groupRepo : groups.values()) {
328                    addAll(groupRepo.getResources());
329                }
330            }, groupLoaders)).get();
331        backupGroups = groups;
332        return true;
333    }
334
335    private CompletableFuture<Void>
336            scanRequested(Map<String, MavenGroupRepository> oldGroups) {
337        return CompletableFuture.allOf(
338            Arrays.stream(indexDbDir.toFile().list()).parallel()
339                .filter(dir -> dir.matches("^[A-Za-z].*")
340                    && !"index.xml".equals(dir)
341                    && !"dependencies".equals(dir))
342                .map(groupId -> CompletableFuture
343                    .runAsync(() -> restoreGroup(oldGroups, groupId, true),
344                        groupLoaders))
345                .toArray(CompletableFuture[]::new));
346    }
347
348    private CompletableFuture<Void>
349            scanDependencies(Map<String, MavenGroupRepository> oldGroups) {
350        if (!depsDir.toFile().canRead() || !depsDir.toFile().isDirectory()) {
351            return CompletableFuture.completedFuture(null);
352        }
353        return CompletableFuture.allOf(
354            Arrays.stream(depsDir.toFile().list()).parallel()
355                .filter(dir -> dir.matches("^[A-Za-z].*"))
356                .filter(dir -> {
357                    if (groups.containsKey(dir)) {
358                        // Is/has become explicitly requested
359                        try {
360                            Files.walk(depsDir.resolve(dir))
361                                .sorted(Comparator.reverseOrder())
362                                .map(Path::toFile)
363                                .forEach(File::delete);
364                        } catch (IOException e) {
365                            throw new IllegalStateException(e);
366                        }
367                        return false;
368                    }
369                    return true;
370                })
371                .map(groupId -> CompletableFuture
372                    .runAsync(() -> restoreGroup(oldGroups, groupId, false),
373                        groupLoaders))
374                .toArray(CompletableFuture[]::new));
375    }
376
377    private void restoreGroup(Map<String, MavenGroupRepository> oldGroups,
378            String groupId, boolean requested) {
379        if (oldGroups.containsKey(groupId)) {
380            // Reuse existing.
381            MavenGroupRepository groupRepo = oldGroups.get(groupId);
382            groupRepo.reset((requested ? indexDbDir : depsDir).resolve(groupId),
383                requested);
384            groups.put(groupId, groupRepo);
385            return;
386        }
387        try {
388            MavenGroupRepository groupRepo
389                = new MavenGroupRepository(groupId,
390                    (requested ? indexDbDir : depsDir).resolve(groupId),
391                    requested, this, client, reporter);
392            groups.put(groupId, groupRepo);
393        } catch (IOException e) {
394            reporter.exception(e,
395                "Cannot create group repository for %s: %s",
396                groupId, e.getMessage());
397        }
398    }
399
400    private void writeFederatedIndex(OutputStream out) {
401        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
402        dbf.setNamespaceAware(true);
403        Document doc;
404        try {
405            doc = dbf.newDocumentBuilder().newDocument();
406        } catch (ParserConfigurationException e) {
407            return;
408        }
409        @SuppressWarnings("PMD.AvoidFinalLocalVariable")
410        final String repoNs = "http://www.osgi.org/xmlns/repository/v1.0.0";
411        // <repository name=... increment=...>
412        Element repoNode = doc.createElementNS(repoNs, "repository");
413        repoNode.setAttribute("name", name);
414        repoNode.setAttribute("increment",
415            Long.toString(System.currentTimeMillis()));
416        doc.appendChild(repoNode);
417        for (Map.Entry<String, MavenGroupRepository> repo : groups.entrySet()) {
418            // <referral url=...>
419            Element referral = doc.createElementNS(repoNs, "referral");
420            referral.setAttribute("url",
421                (repo.getValue().isRequested() ? "" : "dependencies/")
422                    + repo.getKey() + "/index.xml");
423            repoNode.appendChild(referral);
424        }
425        // Write federated index.
426        try {
427            Transformer transformer
428                = TransformerFactory.newInstance().newTransformer();
429            transformer.setOutputProperty(OutputKeys.INDENT, "yes");
430            DOMSource source = new DOMSource(doc);
431            transformer.transform(source, new StreamResult(out));
432        } catch (TransformerException e) {
433            reporter.exception(e, "Cannot write federated index: %s",
434                e.getMessage());
435        }
436    }
437
438}