• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Commonjava / indy-tracking-service / 16665517501

01 Aug 2025 03:28AM UTC coverage: 67.018% (+0.3%) from 66.761%
16665517501

push

github

web-flow
Add action after batch delete to clean up empty parent folders (#66)

27 of 33 new or added lines in 3 files covered. (81.82%)

1207 of 1801 relevant lines covered (67.02%)

1.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.32
/src/main/java/org/commonjava/indy/service/tracking/controller/AdminController.java
1
/**
2
 * Copyright (C) 2022-2023 Red Hat, Inc. (https://github.com/Commonjava/indy-tracking-service)
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *         http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
package org.commonjava.indy.service.tracking.controller;
17

18
import com.fasterxml.jackson.databind.ObjectMapper;
19
import org.commonjava.indy.service.tracking.Constants;
20
import org.commonjava.indy.service.tracking.client.content.BatchDeleteRequest;
21
import org.commonjava.indy.service.tracking.client.content.ContentService;
22
import org.commonjava.indy.service.tracking.client.promote.PathsPromoteTrackingRecords;
23
import org.commonjava.indy.service.tracking.client.promote.PromoteService;
24
import org.commonjava.indy.service.tracking.config.IndyTrackingConfiguration;
25
import org.commonjava.indy.service.tracking.data.cassandra.CassandraTrackingQuery;
26
import org.commonjava.indy.service.tracking.exception.ContentException;
27
import org.commonjava.indy.service.tracking.exception.IndyWorkflowException;
28
import org.commonjava.indy.service.tracking.jaxrs.DTOStreamingOutput;
29
import org.commonjava.indy.service.tracking.model.StoreKey;
30
import org.commonjava.indy.service.tracking.model.TrackedContent;
31
import org.commonjava.indy.service.tracking.model.TrackedContentEntry;
32
import org.commonjava.indy.service.tracking.model.TrackingKey;
33
import org.commonjava.indy.service.tracking.model.dto.ContentDTO;
34
import org.commonjava.indy.service.tracking.model.dto.ContentEntryDTO;
35
import org.commonjava.indy.service.tracking.model.dto.ContentTransferDTO;
36
import org.commonjava.indy.service.tracking.model.dto.TrackedContentDTO;
37
import org.commonjava.indy.service.tracking.model.dto.TrackedContentEntryDTO;
38
import org.commonjava.indy.service.tracking.model.dto.TrackingIdsDTO;
39
import org.commonjava.indy.service.tracking.util.UrlUtils;
40
import org.eclipse.microprofile.rest.client.inject.RestClient;
41
import org.slf4j.Logger;
42
import org.slf4j.LoggerFactory;
43

44
import jakarta.enterprise.context.ApplicationScoped;
45
import jakarta.inject.Inject;
46
import jakarta.ws.rs.core.Response;
47
import java.io.File;
48
import java.io.IOException;
49
import java.io.InputStream;
50
import java.net.MalformedURLException;
51
import java.nio.file.Paths;
52
import java.util.HashSet;
53
import java.util.Map;
54
import java.util.Set;
55
import java.util.TreeSet;
56
import java.util.concurrent.atomic.AtomicBoolean;
57
import java.util.stream.Collectors;
58

59
import static org.commonjava.indy.service.tracking.util.TrackingUtils.readZipInputStreamAnd;
60
import static org.commonjava.indy.service.tracking.util.TrackingUtils.zipTrackedContent;
61
import org.commonjava.indy.service.tracking.client.storage.StorageBatchDeleteRequest;
62
import org.commonjava.indy.service.tracking.client.storage.StorageService;
63

64
@ApplicationScoped
65
public class AdminController
66
{
67
    public static final String FOLO_DIR = "folo";
68

69
    public static final String FOLO_SEALED_ZIP = "folo-sealed.zip";
70

71
    private final Logger logger = LoggerFactory.getLogger( getClass() );
2✔
72

73
    @Inject
74
    @RestClient
75
    ContentService contentService;
76

77
    @Inject
78
    @RestClient
79
    PromoteService promoteService;
80

81
    @Inject
82
    @RestClient
83
    StorageService storageService;
84

85
    @Inject
86
    private IndyTrackingConfiguration config;
87

88
    @Inject
89
    private CassandraTrackingQuery recordManager;
90

91
    protected AdminController()
92
    {
2✔
93
    }
2✔
94

95
    public AdminController( final CassandraTrackingQuery recordManager )
96
    {
2✔
97
        this.recordManager = recordManager;
2✔
98
    }
2✔
99

100
    public TrackedContentDTO seal( final String id, final String baseUrl )
101
    {
102
        TrackingKey tk = new TrackingKey( id );
2✔
103
        return constructContentDTO( recordManager.seal( tk ), baseUrl );
2✔
104
    }
105

106
    public void importRecordZip( InputStream stream ) throws IndyWorkflowException
107
    {
108
        try
109
        {
110
            int count = readZipInputStreamAnd( stream, ( record ) -> recordManager.addSealedRecord( record ) );
2✔
111
            logger.debug( "Import records done, size: {}", count );
2✔
112
        }
113
        catch ( Exception e )
×
114
        {
115
            throw new IndyWorkflowException( "Failed to import zip file", e );
×
116
        }
2✔
117
    }
2✔
118

119
    public File renderReportZip() throws IndyWorkflowException
120
    {
121
        Set<TrackedContent> sealed = recordManager.getSealed(); // only care about sealed records
2✔
122
        try
123
        {
124
            File file = Paths.get( config.baseDir().getAbsolutePath(), FOLO_DIR, FOLO_SEALED_ZIP ).toFile();
2✔
125
            if ( file.exists() )
2✔
126
            {
127
                file.delete();
×
128
            }
129
            file.getParentFile().mkdirs(); // make dirs if not exist
2✔
130

131
            zipTrackedContent( file, sealed );
2✔
132

133
            return file;
2✔
134
        }
135
        catch ( IOException e )
×
136
        {
137
            throw new IndyWorkflowException( "Failed to create zip file", e );
×
138
        }
139
    }
140

141
    public TrackedContentDTO getRecord( final String id, String baseUrl ) throws IndyWorkflowException
142
    {
143
        final TrackingKey tk = new TrackingKey( id );
2✔
144
        return constructContentDTO( recordManager.get( tk ), baseUrl );
2✔
145
    }
146

147
    public TrackedContentDTO getLegacyRecord( final String id, String baseUrl ) throws IndyWorkflowException
148
    {
149
        final TrackingKey tk = new TrackingKey( id );
2✔
150
        return constructContentDTO( recordManager.getLegacy( tk ), baseUrl );
2✔
151
    }
152

153
    public void clearRecord( final String id ) throws ContentException
154
    {
155
        final TrackingKey tk = new TrackingKey( id );
2✔
156
        recordManager.delete( tk );
2✔
157
    }
2✔
158

159
    private TrackedContentDTO constructContentDTO( final TrackedContent content, final String baseUrl )
160
    {
161
        if ( content == null )
2✔
162
        {
163
            return null;
2✔
164
        }
165
        final Set<TrackedContentEntryDTO> uploads = new TreeSet<>();
2✔
166
        for ( TrackedContentEntry entry : content.getUploads() )
2✔
167
        {
168
            uploads.add( constructContentEntryDTO( entry, baseUrl ) );
2✔
169
        }
2✔
170

171
        final Set<TrackedContentEntryDTO> downloads = new TreeSet<>();
2✔
172
        for ( TrackedContentEntry entry : content.getDownloads() )
2✔
173
        {
174
            downloads.add( constructContentEntryDTO( entry, baseUrl ) );
2✔
175
        }
2✔
176
        return new TrackedContentDTO( content.getKey(), uploads, downloads );
2✔
177
    }
178

179
    private TrackedContentEntryDTO constructContentEntryDTO( final TrackedContentEntry entry, String apiBaseUrl )
180
    {
181
        if ( entry == null )
2✔
182
        {
183
            return null;
×
184
        }
185
        TrackedContentEntryDTO entryDTO =
2✔
186
                        new TrackedContentEntryDTO( entry.getStoreKey(), entry.getAccessChannel(), entry.getPath() );
2✔
187

188
        try
189
        {
190
            entryDTO.setLocalUrl( UrlUtils.buildUrl( apiBaseUrl, "content", entryDTO.getStoreKey().getPackageType(),
2✔
191
                                                     entryDTO.getStoreKey().getType().singularEndpointName(),
2✔
192
                                                     entryDTO.getStoreKey().getName(), entryDTO.getPath() ) );
2✔
193
        }
194
        catch ( MalformedURLException e )
×
195
        {
196
            logger.warn( String.format( "Cannot formulate local URL!\n  Base URL: %s"
×
197
                                                        + "\n  Store: %s\n  Path: %s\n  Record: %s\n  Reason: %s",
198
                                        apiBaseUrl, entry.getStoreKey(), entry.getPath(), entry.getTrackingKey(),
×
199
                                        e.getMessage() ), e );
×
200
        }
2✔
201

202
        entryDTO.setOriginUrl( entry.getOriginUrl() );
2✔
203
        entryDTO.setMd5( entry.getMd5() );
2✔
204
        entryDTO.setSha1( entry.getSha1() );
2✔
205
        entryDTO.setSha256( entry.getSha256() );
2✔
206
        entryDTO.setSize( entry.getSize() );
2✔
207
        entryDTO.setTimestamps( entry.getTimestamps() );
2✔
208
        return entryDTO;
2✔
209
    }
210

211
    private Set<ContentTransferDTO> constructTransferDTOSet( final Set<TrackedContentEntry> entries )
212
    {
213
        if ( entries == null )
2✔
214
        {
215
            return null;
×
216
        }
217
        Set<ContentTransferDTO> cut_entries = new HashSet<>();
2✔
218
        for ( TrackedContentEntry entry : entries )
2✔
219
        {
220
            ContentTransferDTO cut_entry = new ContentTransferDTO( entry.getStoreKey(), entry.getTrackingKey(),
2✔
221
                                                                   entry.getAccessChannel(), entry.getPath(),
2✔
222
                                                                   entry.getOriginUrl(), entry.getEffect() );
2✔
223
            cut_entries.add( cut_entry );
2✔
224
        }
2✔
225

226
        return cut_entries;
2✔
227
    }
228

229
    private ContentDTO convertToContentDTO( final TrackedContent record )
230
    {
231
        ContentDTO dto = new ContentDTO();
2✔
232
        dto.setKey( record.getKey() );
2✔
233
        Set<ContentEntryDTO> uploads = new HashSet<>();
2✔
234
        Set<ContentEntryDTO> downloads = new HashSet<>();
2✔
235
        for ( TrackedContentEntry entry : record.getUploads() )
2✔
236
        {
237
            uploads.add( convertToCOntentEntryDTO( entry ) );
×
238
        }
×
239
        for ( TrackedContentEntry entry : record.getDownloads() )
2✔
240
        {
241
            downloads.add( convertToCOntentEntryDTO( entry ) );
2✔
242
        }
2✔
243
        dto.setUploads( uploads );
2✔
244
        dto.getDownloads( downloads );
2✔
245
        return dto;
2✔
246
    }
247

248
    private ContentEntryDTO convertToCOntentEntryDTO( TrackedContentEntry entry )
249
    {
250
        ContentEntryDTO entryDTO = new ContentEntryDTO();
2✔
251
        entryDTO.setStoreKey( entry.getStoreKey() );
2✔
252
        entryDTO.setPath( entry.getPath() );
2✔
253
        return entryDTO;
2✔
254
    }
255

256
    public TrackingIdsDTO getLegacyTrackingIds()
257
    {
258
        logger.info( "Get legacy folo ids" );
2✔
259
        TrackingIdsDTO ret = null;
2✔
260
        Set<String> sealed = recordManager.getLegacyTrackingKeys()
2✔
261
                                          .stream()
2✔
262
                                          .map( TrackingKey::getId )
2✔
263
                                          .collect( Collectors.toSet() );
2✔
264
        if ( sealed != null )
2✔
265
        {
266
            ret = new TrackingIdsDTO();
2✔
267
            ret.setSealed( sealed );
2✔
268
        }
269
        return ret;
2✔
270
    }
271

272
    public TrackingIdsDTO getTrackingIds( final Set<Constants.TRACKING_TYPE> types )
273
    {
274

275
        Set<String> inProgress = null;
2✔
276
        if ( types.contains( Constants.TRACKING_TYPE.IN_PROGRESS ) )
2✔
277
        {
278
            inProgress = recordManager.getInProgressTrackingKey()
×
279
                                      .stream()
×
280
                                      .map( TrackingKey::getId )
×
281
                                      .collect( Collectors.toSet() );
×
282
        }
283

284
        Set<String> sealed = null;
2✔
285
        if ( types.contains( Constants.TRACKING_TYPE.SEALED ) )
2✔
286
        {
287
            sealed = recordManager.getSealedTrackingKey()
2✔
288
                                  .stream()
2✔
289
                                  .map( TrackingKey::getId )
2✔
290
                                  .collect( Collectors.toSet() );
2✔
291
        }
292

293
        if ( ( inProgress != null && !inProgress.isEmpty() ) || ( sealed != null && !sealed.isEmpty() ) )
2✔
294
        {
295
            return new TrackingIdsDTO( inProgress, sealed );
2✔
296
        }
297
        return null;
×
298
    }
299

300
    public TrackedContentDTO recalculateRecord( final String id, final String baseUrl ) throws IndyWorkflowException
301
    {
302
        TrackingKey trackingKey = new TrackingKey( id );
2✔
303
        TrackedContent record = recordManager.get( trackingKey );
2✔
304

305
        if ( record == null )
2✔
306
        {
307
            return null;
2✔
308
        }
309

310
        AtomicBoolean failed = new AtomicBoolean( false );
2✔
311

312
        Set<TrackedContentEntry> recalculatedUploads = recalculateEntrySet( record.getUploads(), failed );
2✔
313
        Set<TrackedContentEntry> recalculatedDownloads = null;
2✔
314
        if ( !failed.get() )
2✔
315
        {
316
            recalculatedDownloads = recalculateEntrySet( record.getDownloads(), failed );
2✔
317
        }
318

319
        if ( failed.get() )
2✔
320
        {
321
            throw new IndyWorkflowException(
×
322
                            "Failed to recalculate tracking record: %s. See Indy logs for more information", id );
323
        }
324

325
        TrackedContent recalculated = new TrackedContent( record.getKey(), recalculatedUploads, recalculatedDownloads );
2✔
326
        recordManager.replaceTrackingRecord( recalculated );
2✔
327

328
        return constructContentDTO( recalculated, baseUrl );
2✔
329
    }
330

331
    private Set<TrackedContentEntry> recalculateEntrySet( final Set<TrackedContentEntry> entries,
332
                                                          final AtomicBoolean failed )
333
    {
334
        Set<ContentTransferDTO> transfer_entries = constructTransferDTOSet( entries );
2✔
335
        try (Response response = contentService.recalculateEntrySet( transfer_entries ))
2✔
336
        {
337
            return (Set<TrackedContentEntry>) response.readEntity( DTOStreamingOutput.class ).getDto();
2✔
338
        }
339
        catch ( Exception e )
×
340
        {
341
            failed.set( true );
×
342
            return null;
×
343
        }
344
    }
345

346
    public File getZipRepository( String id )
347
    {
348
        final TrackingKey tk = new TrackingKey( id );
2✔
349
        final TrackedContent record = recordManager.get( tk );
2✔
350
        ContentDTO dto = convertToContentDTO( record );
2✔
351
        return contentService.getZipRepository( dto );
2✔
352
    }
353

354
    public boolean recordArtifact( TrackedContentEntry contentEntry )
355
    {
356
        boolean isRecorded = false;
2✔
357
        try
358
        {
359
            isRecorded = recordManager.recordArtifact( contentEntry );
2✔
360
        }
361
        catch ( final ContentException | IndyWorkflowException e )
×
362
        {
363
            logger.error( "Failed to record entry: {}.", contentEntry, e );
×
364
        }
2✔
365
        return isRecorded;
2✔
366
    }
367

368
    /**
369
     * Additional check for batch deletion. It retrieves the promotion record by the trackingID
370
     * to find the target store associated with the promotion. If the target store does not match given store,
371
     * return failed delete validation result.
372
     */
373
    public boolean deletionAdditionalGuardCheck( BatchDeleteRequest deleteRequest )
374
    {
375
        if ( !config.deletionAdditionalGuardCheck() )
2✔
376
        {
377
            return true; // as passed if guard check is not enabled
×
378
        }
379

380
        String trackingID = deleteRequest.getTrackingID();
2✔
381
        final StoreKey givenStore = deleteRequest.getStoreKey();
2✔
382
        final AtomicBoolean isOk = new AtomicBoolean(false);
2✔
383
        try
384
        {
385
            Response resp = promoteService.getPromoteRecords( trackingID );
2✔
386
            if (!isSuccess(resp))
2✔
387
            {
388
                if ( resp.getStatus() == Response.Status.NOT_FOUND.getStatusCode() )
×
389
                {
390
                    // Record not exists. It is common because the guide check can not cover old artifacts. Old
391
                    // artifacts were promoted without tracking. We just print a log and allow it.
392
                    logger.info("Promote tracking record not found but allow deletion, trackingID: {}", trackingID);
×
393
                    return true;
×
394
                }
395
                logger.warn( "Deletion guard check failed, status:" + resp.getStatus() );
×
396
                return false;
×
397
            }
398
            PathsPromoteTrackingRecords promoteTrackingRecords = resp.readEntity(PathsPromoteTrackingRecords.class);
2✔
399
            Map<String, PathsPromoteTrackingRecords.PathsPromoteResult> resultMap = promoteTrackingRecords.getResultMap();
2✔
400
            if ( resultMap != null )
2✔
401
            {
402
                resultMap.forEach( (k,v) -> {
2✔
403
                    if (v.getRequest().getTarget().equals(givenStore))
2✔
404
                    {
405
                        isOk.set(true); // set true if any match found
2✔
406
                    }
407
                });
2✔
408
            }
409
        }
410
        catch (Exception e)
×
411
        {
412
            logger.warn("Deletion guard check failed", e);
×
413
            return false;
×
414
        }
2✔
415
        logger.info("Deletion guard check, trackingID: {}, passed: {}", trackingID, isOk.get());
2✔
416
        return isOk.get();
2✔
417
    }
418

419
    /**
420
     * Post-action after successful batch delete: cleans up empty parent folders.
421
     * <p>
422
     * For each deleted path, collects its immediate parent folder (one level up).
423
     * Then calls the storage service to clean up these folders, relying on the
424
     * storage API to handle ancestor folders as needed.
425
     * </p>
426
     *
427
     * @param filesystem the target filesystem/storeKey as a string
428
     * @param paths the set of deleted file paths
429
     */
430
    public void cleanupEmptyFolders(String filesystem, Set<String> paths) {
431
        logger.info("Post-action: cleanupEmptyFolder, filesystem={}, paths={}", filesystem, paths);
2✔
432
        if (paths == null || paths.isEmpty()) {
2✔
NEW
433
            logger.info("No paths to process for cleanup.");
×
NEW
434
            return;
×
435
        }
436
        Set<String> folders = new HashSet<>();
2✔
437
        for (String path : paths) {
2✔
438
            int idx = path.lastIndexOf('/');
2✔
439
            if (idx > 0) {
2✔
440
                String folder = path.substring(0, idx);
2✔
441
                folders.add(folder);
2✔
442
            }
443
        }
2✔
444
        StorageBatchDeleteRequest req = new StorageBatchDeleteRequest();
2✔
445
        req.setFilesystem(filesystem);
2✔
446
        req.setPaths(folders);
2✔
447
        try {
448
            Response resp = storageService.cleanupEmptyFolders(req);
2✔
449
            logger.info("Cleanup empty folders, req: {}, status {}", req, resp.getStatus());
2✔
NEW
450
        } catch (Exception e) {
×
NEW
451
            logger.warn("Failed to cleanup folders, request: {}, error: {}", req, e.getMessage(), e);
×
452
        }
2✔
453
    }
2✔
454

455
    private boolean isSuccess(Response resp) {
456
        return Response.Status.fromStatusCode(resp.getStatus()).getFamily()
2✔
457
                == Response.Status.Family.SUCCESSFUL;
458
    }
459
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc