From b5bb8e8293c0df78f7528392de00bc337f7da522 Mon Sep 17 00:00:00 2001
From: Martin Weise <martin.weise@tuwien.ac.at>
Date: Tue, 1 Apr 2025 08:27:35 +0000
Subject: [PATCH] Added scheduled scraping that removes stale datasets

---
 .docs/api/data-service.md                     |   8 ++++-
 .../target/create-event-listener.jar          | Bin 10141 -> 10140 bytes
 ...ation.java => DataServiceApplication.java} |   6 ++--
 .../src/main/resources/application.yml        |   2 ++
 .../StorageServiceIntegrationTest.java        |  32 ++++++++++++++++-
 .../src/test/resources/application.properties |   1 +
 .../main/java/at/tuwien/config/S3Config.java  |   3 ++
 .../at/tuwien/service/StorageService.java     |   5 +++
 .../service/impl/StorageServiceS3Impl.java    |  33 +++++++++++++++---
 .../at/tuwien/timer/StaleObjectTimer.java     |  25 +++++++++++++
 .../service/impl/StorageServiceS3Impl.java    |   4 ++-
 helm/dbrepo/files/create-event-listener.jar   | Bin 10141 -> 10140 bytes
 12 files changed, 109 insertions(+), 10 deletions(-)
 rename dbrepo-data-service/rest-service/src/main/java/at/tuwien/{DbrepoDataServiceApplication.java => DataServiceApplication.java} (57%)
 create mode 100644 dbrepo-data-service/services/src/main/java/at/tuwien/timer/StaleObjectTimer.java

diff --git a/.docs/api/data-service.md b/.docs/api/data-service.md
index ff27c03586..66089a1cd6 100644
--- a/.docs/api/data-service.md
+++ b/.docs/api/data-service.md
@@ -46,12 +46,18 @@ everytime e.g. a sensor measurement is inserted. By default, this information is
 administrators can disable this behavior by setting `CREDENTIAL_CACHE_TIMEOUT=0` (cache is deleted after 0 seconds).
 
 
-## Upload
+## Storage
 
 The Data Service also is capable to upload files to the S3 backend. The default limit 
 of [`Tomcat`](https://spring.io/guides/gs/uploading-files#_tuning_file_upload_limits) in Spring Boot is configured to be
 `2GB`. You can provide your own limit with setting `MAX_UPLOAD_SIZE`.
 
+By default, the Data Service removes datasets older than 24 hours on a regular basis every 60 minutes. You can set the
+`MAX_AGE` (in seconds) and `S3_STALE_CRON` to fit your use-case. You can disable this feature by setting `S3_STALE_CRON`
+to `-`, this may lead to storage issues as no space will be available inevitably. Note 
+that [Spring Boot uses its own flavor](https://spring.io/blog/2020/11/10/new-in-spring-5-3-improved-cron-expressions#usage)
+of cron syntax.
+
 ## Limitations
 
 * Views in DBRepo can only have 63-character length (it is assumed only internal views have the maximum length of 64
diff --git a/dbrepo-auth-service/listeners/target/create-event-listener.jar b/dbrepo-auth-service/listeners/target/create-event-listener.jar
index c45fcf9fa8e58ddb816cd6f9afb13cfd470c001b..c4222b60517827f2c2834533c8aa0d2a51a90548 100644
GIT binary patch
delta 969
zcmbR1KgVA<z?+#xgn@yBgTcb1G3qw^|Kmjyg%#@$1|2dG*jv8r{{(ptr7aC#)T#p9
zO4yG4HoNK7W|lfBcjA6?5nbiSE810Nzd5~c`u4~MQ`V|S>tF0ynR|@wkyc-xEYreg
zo1Kq|U09TA?w9M+-KVN#tCZHOX*&P4Zv5MT*6hg^R&L7-xHLi@>9={EnQ>0`T^1j2
z?hg0sQmxZZ)X%HFF?F9$RP64av*}yE>)3Bl%_`#;ap%9f?g*Fe-?Qb6f88|P?4SHU
zxiii#EysDw=b2M}|Bah*h&$^W+gb+Ih9w(Ynf=!VUa|P{NyYzIph1<)G|Md)KHp8u
zJMgVNUY7HkU3J|XhhqyaJ>GKL-cfYk?~I(!_uf|jiD;4glybnnexr8i$J@7}LasdB
zyK0lU^W6zXXJ)I3uUhv+jDOXd#M|sjDobDeV>)h@!N>o6>BknayDL8kMHer+8h-dC
z2Uo!4B{NoK8hUZ_I-7b<W>I_WIP1xEiy2K;ccXWIdUGdVO2yJ8l9m7B+^r=}O$+>W
z51z32#Q=&3E(Qh;22eyy-o&WFZ0^xG`6i<-vxP_FWKJeIFs;R;3Z{dYw7_&dlL?sK
z#^eQ7&&+%lOkZa128nM@WSPSRVr*jP<^<83`2=bNL5#`0inGA9meOQSc+^imrerA(
z;xQlrPB>eD;jQD6$#TjPU^^_8r5R684pmkJn^UPQE%uv{fg!O(Kfs%jNrV~UnN`Zt
zRx(Ursglz2%+x$|6;W^-7#JA%;T({4E=-dXnS^;!4U|`rW-4Tw+{YxVg(87sjy{Sx
zD?rMnCqH14&;)6LBzbd>#;8B@+MK!=7#Kb<FfeGqwJ<O&Y5Y3*zKS{6r;@7Dj7KM%
zss@66+Nmnd6wETYfW>6;Ema<{&`VWmrcZ2>`Pn5V%c((xbk(Gp7z8F4u&7TiR5M_j
zD?7Q531q+6<SS~zU`?;oq?zg!;lgJX`LrP+2XP!I8iYMU&LuN2Fl=OCU=T)eoXq4x
Lbv3pPN+3Z1`wu(e

delta 1019
zcmbQ^Ki6M4z?+#xgn@yBgMp>CK8o+`Pxs=9!ix32#fKCG_NJ@-Z?tFlAt*dMdTaZ&
zi)C)v?;WnS87fNNIP&X#_r+r?FK1RWh<)4m{^PW%2372QFQp@vYfqk}DBW`KPLsol
z7jI@vnwoKNLiOsRyG^ReVsiyHyNOH2rd01*>G(6-Q(}(N-pgJK*1N=;icOg06SYEH
zOX|!@=G$4U+v`uvtG+UIpYN<Sn=~WOXg%++nPi&sBrrk3W@;&;s>Gk!@7aF!PVlWe
z@qcph?zm+t?rUOC{d`!EQ1x8GjGecQ^C(MEZdsVcmfl0hnjWWD+VJeTxG`RAk*Mu`
zW)EGT`X5&X^WSzaUe33~WlE$>>F@NwU(fZnX4>tl?eCxYUQw36{w3?~K-Ib7#~Kdl
zy$_kWFZ&JK^SX1=CqqI%*~)|lcGk)p%zv`#H@8UC`kVH1#BM1{Y<PYsJLBt2u_+T4
zpH!Gwv}#g{#Z)7?BbPH|jxV;@CGEIm5~t4+tNXWaJ$iK~U&<niA+C!5VBFRcr#%b0
z#qv`mYM4MV!NtJ9!N9;$%UC~oBclp4b8Y?P8;rWlEVcEMIhf?Yv?h}(m=0vp0@HO&
zCSZCilNVS$6Z2UxeTlgnB)&O;WeyXV@t=*G6U^Whs1XD)Cif`L0@IpGlR4qBKl!MV
zC0LYES(<U_WLaei1?JlNC<Y`T1ZN8{ymdUoIQb#7$mC3A9utrua5)AB24*-1r0@?T
z14Ckoet<V4lL#}+r}dLpDoe}CGJ&N^O3O1-^DtCARF-CRo%~lB5&&{4(u_ru8&zbq
z(M&>dqdtn&D<?Oq*n$1=XI`6A7Xt&s2L=WP4Y(Qxh9!;Pm?jG{Yfa`<<pC@7`OUIQ
zkAZ<fhk=2?2}R*C=E)D4T_#tm@+g2j{Qpz)Zhs~QhHI=03`QslLs%x~vzSc23027b
zPw>wz4hDvG0t^gdC<;HbP3B{lm@KQt0}go|HEAYBfywzS>XQr93>fE4UZ~~@mUyWq
z&D5YcxtB?L@_i<;$!8S#pkaW>!NMLP=aLy17&bC6FbJbqB0IT2U5#y{5=amLw(~^1

diff --git a/dbrepo-data-service/rest-service/src/main/java/at/tuwien/DbrepoDataServiceApplication.java b/dbrepo-data-service/rest-service/src/main/java/at/tuwien/DataServiceApplication.java
similarity index 57%
rename from dbrepo-data-service/rest-service/src/main/java/at/tuwien/DbrepoDataServiceApplication.java
rename to dbrepo-data-service/rest-service/src/main/java/at/tuwien/DataServiceApplication.java
index 1f38a7920a..95a70f0bb2 100644
--- a/dbrepo-data-service/rest-service/src/main/java/at/tuwien/DbrepoDataServiceApplication.java
+++ b/dbrepo-data-service/rest-service/src/main/java/at/tuwien/DataServiceApplication.java
@@ -3,13 +3,15 @@ package at.tuwien;
 import lombok.extern.log4j.Log4j2;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
 
 @Log4j2
+@EnableScheduling
 @SpringBootApplication
-public class DbrepoDataServiceApplication {
+public class DataServiceApplication {
 
     public static void main(String[] args) {
-        SpringApplication.run(DbrepoDataServiceApplication.class, args);
+        SpringApplication.run(DataServiceApplication.class, args);
     }
 
 }
diff --git a/dbrepo-data-service/rest-service/src/main/resources/application.yml b/dbrepo-data-service/rest-service/src/main/resources/application.yml
index f008cde99b..a22eb40a20 100644
--- a/dbrepo-data-service/rest-service/src/main/resources/application.yml
+++ b/dbrepo-data-service/rest-service/src/main/resources/application.yml
@@ -64,6 +64,8 @@ dbrepo:
     accessKeyId: "${S3_ACCESS_KEY_ID:seaweedfsadmin}"
     secretAccessKey: "${S3_SECRET_ACCESS_KEY:seaweedfsadmin}"
     bucket: "${S3_BUCKET:dbrepo}"
+    maxAge: "${S3_MAX_AGE:86400}"
+    cron: "${S3_STALE_CRON:0 */60 * * * *}"
   system:
     username: "${SYSTEM_USERNAME:admin}"
     password: "${SYSTEM_PASSWORD:admin}"
diff --git a/dbrepo-data-service/rest-service/src/test/java/at/tuwien/service/StorageServiceIntegrationTest.java b/dbrepo-data-service/rest-service/src/test/java/at/tuwien/service/StorageServiceIntegrationTest.java
index dd563deb70..9d923542a1 100644
--- a/dbrepo-data-service/rest-service/src/test/java/at/tuwien/service/StorageServiceIntegrationTest.java
+++ b/dbrepo-data-service/rest-service/src/test/java/at/tuwien/service/StorageServiceIntegrationTest.java
@@ -1,12 +1,12 @@
 package at.tuwien.service;
 
 import at.ac.tuwien.ifs.dbrepo.core.api.ExportResourceDto;
-import at.tuwien.config.S3Config;
 import at.ac.tuwien.ifs.dbrepo.core.exception.MalformedException;
 import at.ac.tuwien.ifs.dbrepo.core.exception.StorageNotFoundException;
 import at.ac.tuwien.ifs.dbrepo.core.exception.StorageUnavailableException;
 import at.ac.tuwien.ifs.dbrepo.core.exception.TableMalformedException;
 import at.ac.tuwien.ifs.dbrepo.core.test.BaseTest;
+import at.tuwien.config.S3Config;
 import lombok.extern.log4j.Log4j2;
 import org.apache.commons.io.FileUtils;
 import org.apache.spark.sql.Dataset;
@@ -31,6 +31,7 @@ import org.testcontainers.junit.jupiter.Testcontainers;
 import software.amazon.awssdk.core.sync.RequestBody;
 import software.amazon.awssdk.services.s3.S3Client;
 import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
+import software.amazon.awssdk.services.s3.model.ListObjectsRequest;
 import software.amazon.awssdk.services.s3.model.PutObjectRequest;
 
 import java.io.*;
@@ -232,6 +233,35 @@ public class StorageServiceIntegrationTest extends BaseTest {
         assertEquals("", lines.get(0));
     }
 
+    @Test
+    public void deleteStaleObjects_none_succeeds() {
+
+        /* mock */
+        s3Client.putObject(PutObjectRequest.builder()
+                .key("s3key")
+                .bucket(s3Config.getS3Bucket())
+                .build(), RequestBody.fromFile(new File("src/test/resources/csv/weather_aus.csv")));
+
+        /* test */
+        storageService.deleteStaleObjects();
+        assertEquals(1, s3Client.listObjects(ListObjectsRequest.builder().bucket(s3Config.getS3Bucket()).build()).contents().size());
+    }
+
+    @Test
+    public void deleteStaleObjects_succeeds() throws InterruptedException {
+
+        /* mock */
+        s3Client.putObject(PutObjectRequest.builder()
+                .key("s3key")
+                .bucket(s3Config.getS3Bucket())
+                .build(), RequestBody.fromFile(new File("src/test/resources/csv/weather_aus.csv")));
+
+        /* test */
+        Thread.sleep(4000);
+        storageService.deleteStaleObjects();
+        assertEquals(0, s3Client.listObjects(ListObjectsRequest.builder().bucket(s3Config.getS3Bucket()).build()).contents().size());
+    }
+
     @ParameterizedTest
     @Disabled("cannot fix")
     @MethodSource("loadDataset_arguments")
diff --git a/dbrepo-data-service/rest-service/src/test/resources/application.properties b/dbrepo-data-service/rest-service/src/test/resources/application.properties
index a0bb7de2bb..f1d57b6786 100644
--- a/dbrepo-data-service/rest-service/src/test/resources/application.properties
+++ b/dbrepo-data-service/rest-service/src/test/resources/application.properties
@@ -33,3 +33,4 @@ spring.rabbitmq.password=guest
 # s3
 dbrepo.s3.accessKeyId=minioadmin
 dbrepo.s3.secretAccessKey=minioadmin
+dbrepo.s3.maxAge=3
diff --git a/dbrepo-data-service/services/src/main/java/at/tuwien/config/S3Config.java b/dbrepo-data-service/services/src/main/java/at/tuwien/config/S3Config.java
index c5aeb968d5..726692e55d 100644
--- a/dbrepo-data-service/services/src/main/java/at/tuwien/config/S3Config.java
+++ b/dbrepo-data-service/services/src/main/java/at/tuwien/config/S3Config.java
@@ -30,6 +30,9 @@ public class S3Config {
     @Value("${dbrepo.s3.bucket}")
     private String s3Bucket;
 
+    @Value("${dbrepo.s3.maxAge}")
+    private Integer maxAge;
+
     @Bean
     public S3Client s3client() {
         final AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(
diff --git a/dbrepo-data-service/services/src/main/java/at/tuwien/service/StorageService.java b/dbrepo-data-service/services/src/main/java/at/tuwien/service/StorageService.java
index 65896d53e3..0e126e27a6 100644
--- a/dbrepo-data-service/services/src/main/java/at/tuwien/service/StorageService.java
+++ b/dbrepo-data-service/services/src/main/java/at/tuwien/service/StorageService.java
@@ -9,6 +9,7 @@ import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
 
 import java.io.InputStream;
+import java.time.Instant;
 import java.util.List;
 
 public interface StorageService {
@@ -47,6 +48,10 @@ public interface StorageService {
      */
     byte[] getBytes(String bucket, String key) throws StorageUnavailableException, StorageNotFoundException;
 
+    void deleteObject(String bucket, String key);
+
+    void deleteStaleObjects();
+
     /**
      * Loads an object of the default export bucket from the Storage Service into an export resource.
      *
diff --git a/dbrepo-data-service/services/src/main/java/at/tuwien/service/impl/StorageServiceS3Impl.java b/dbrepo-data-service/services/src/main/java/at/tuwien/service/impl/StorageServiceS3Impl.java
index 76bfb60c4d..bb75d7bac2 100644
--- a/dbrepo-data-service/services/src/main/java/at/tuwien/service/impl/StorageServiceS3Impl.java
+++ b/dbrepo-data-service/services/src/main/java/at/tuwien/service/impl/StorageServiceS3Impl.java
@@ -1,11 +1,11 @@
 package at.tuwien.service.impl;
 
 import at.ac.tuwien.ifs.dbrepo.core.api.ExportResourceDto;
-import at.tuwien.config.S3Config;
 import at.ac.tuwien.ifs.dbrepo.core.exception.MalformedException;
 import at.ac.tuwien.ifs.dbrepo.core.exception.StorageNotFoundException;
 import at.ac.tuwien.ifs.dbrepo.core.exception.StorageUnavailableException;
 import at.ac.tuwien.ifs.dbrepo.core.exception.TableMalformedException;
+import at.tuwien.config.S3Config;
 import at.tuwien.service.StorageService;
 import lombok.extern.log4j.Log4j2;
 import org.apache.commons.lang3.RandomStringUtils;
@@ -17,13 +17,12 @@ import org.springframework.core.io.InputStreamResource;
 import org.springframework.stereotype.Service;
 import software.amazon.awssdk.core.sync.RequestBody;
 import software.amazon.awssdk.services.s3.S3Client;
-import software.amazon.awssdk.services.s3.model.GetObjectRequest;
-import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
-import software.amazon.awssdk.services.s3.model.PutObjectRequest;
-import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.model.*;
 
 import java.io.*;
 import java.nio.charset.Charset;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -91,6 +90,30 @@ public class StorageServiceS3Impl implements StorageService {
         }
     }
 
+    @Override
+    public void deleteObject(String bucket, String key) {
+        log.trace("delete object with key {} from bucket: {}", key, bucket);
+        s3Client.deleteObject(DeleteObjectRequest.builder()
+                .bucket(bucket)
+                .key(key)
+                .build());
+    }
+
+    @Override
+    public void deleteStaleObjects() {
+        log.trace("list stale objects in bucket: {}", s3Config.getS3Bucket());
+        final List<String> keys = s3Client.listObjects(ListObjectsRequest.builder()
+                        .bucket(s3Config.getS3Bucket())
+                        .build())
+                .contents()
+                .stream()
+                .filter(o -> o.lastModified().isBefore(Instant.now().minus(s3Config.getMaxAge(), ChronoUnit.SECONDS)))
+                .map(S3Object::key)
+                .toList();
+        keys.forEach(key -> deleteObject(s3Config.getS3Bucket(), key));
+        log.info("Deleted {} stale object(s) in bucket: {}", keys.size(), s3Config.getS3Bucket());
+    }
+
     @Override
     public ExportResourceDto getResource(String key) throws StorageNotFoundException, StorageUnavailableException {
         return getResource(s3Config.getS3Bucket(), key);
diff --git a/dbrepo-data-service/services/src/main/java/at/tuwien/timer/StaleObjectTimer.java b/dbrepo-data-service/services/src/main/java/at/tuwien/timer/StaleObjectTimer.java
new file mode 100644
index 0000000000..de30299f3d
--- /dev/null
+++ b/dbrepo-data-service/services/src/main/java/at/tuwien/timer/StaleObjectTimer.java
@@ -0,0 +1,25 @@
+package at.tuwien.timer;
+
+import at.tuwien.service.StorageService;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Log4j2
+@Component
+public class StaleObjectTimer {
+
+    private final StorageService storageService;
+
+    @Autowired
+    public StaleObjectTimer(StorageService storageService) {
+        this.storageService = storageService;
+    }
+
+    @Scheduled(cron = "${dbrepo.s3.cron}")
+    public void deleteStaleObjects() {
+        storageService.deleteStaleObjects();
+    }
+
+}
diff --git a/dbrepo-metadata-service/services/src/main/java/at/ac/tuwien/ifs/dbrepo/service/impl/StorageServiceS3Impl.java b/dbrepo-metadata-service/services/src/main/java/at/ac/tuwien/ifs/dbrepo/service/impl/StorageServiceS3Impl.java
index 10e04eb291..345529486a 100644
--- a/dbrepo-metadata-service/services/src/main/java/at/ac/tuwien/ifs/dbrepo/service/impl/StorageServiceS3Impl.java
+++ b/dbrepo-metadata-service/services/src/main/java/at/ac/tuwien/ifs/dbrepo/service/impl/StorageServiceS3Impl.java
@@ -8,7 +8,9 @@ import lombok.extern.log4j.Log4j2;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import software.amazon.awssdk.services.s3.S3Client;
-import software.amazon.awssdk.services.s3.model.*;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+import software.amazon.awssdk.services.s3.model.S3Exception;
 
 import java.io.IOException;
 import java.io.InputStream;
diff --git a/helm/dbrepo/files/create-event-listener.jar b/helm/dbrepo/files/create-event-listener.jar
index c45fcf9fa8e58ddb816cd6f9afb13cfd470c001b..c4222b60517827f2c2834533c8aa0d2a51a90548 100644
GIT binary patch
delta 969
zcmbR1KgVA<z?+#xgn@yBgTcb1G3qw^|Kmjyg%#@$1|2dG*jv8r{{(ptr7aC#)T#p9
zO4yG4HoNK7W|lfBcjA6?5nbiSE810Nzd5~c`u4~MQ`V|S>tF0ynR|@wkyc-xEYreg
zo1Kq|U09TA?w9M+-KVN#tCZHOX*&P4Zv5MT*6hg^R&L7-xHLi@>9={EnQ>0`T^1j2
z?hg0sQmxZZ)X%HFF?F9$RP64av*}yE>)3Bl%_`#;ap%9f?g*Fe-?Qb6f88|P?4SHU
zxiii#EysDw=b2M}|Bah*h&$^W+gb+Ih9w(Ynf=!VUa|P{NyYzIph1<)G|Md)KHp8u
zJMgVNUY7HkU3J|XhhqyaJ>GKL-cfYk?~I(!_uf|jiD;4glybnnexr8i$J@7}LasdB
zyK0lU^W6zXXJ)I3uUhv+jDOXd#M|sjDobDeV>)h@!N>o6>BknayDL8kMHer+8h-dC
z2Uo!4B{NoK8hUZ_I-7b<W>I_WIP1xEiy2K;ccXWIdUGdVO2yJ8l9m7B+^r=}O$+>W
z51z32#Q=&3E(Qh;22eyy-o&WFZ0^xG`6i<-vxP_FWKJeIFs;R;3Z{dYw7_&dlL?sK
z#^eQ7&&+%lOkZa128nM@WSPSRVr*jP<^<83`2=bNL5#`0inGA9meOQSc+^imrerA(
z;xQlrPB>eD;jQD6$#TjPU^^_8r5R684pmkJn^UPQE%uv{fg!O(Kfs%jNrV~UnN`Zt
zRx(Ursglz2%+x$|6;W^-7#JA%;T({4E=-dXnS^;!4U|`rW-4Tw+{YxVg(87sjy{Sx
zD?rMnCqH14&;)6LBzbd>#;8B@+MK!=7#Kb<FfeGqwJ<O&Y5Y3*zKS{6r;@7Dj7KM%
zss@66+Nmnd6wETYfW>6;Ema<{&`VWmrcZ2>`Pn5V%c((xbk(Gp7z8F4u&7TiR5M_j
zD?7Q531q+6<SS~zU`?;oq?zg!;lgJX`LrP+2XP!I8iYMU&LuN2Fl=OCU=T)eoXq4x
Lbv3pPN+3Z1`wu(e

delta 1019
zcmbQ^Ki6M4z?+#xgn@yBgMp>CK8o+`Pxs=9!ix32#fKCG_NJ@-Z?tFlAt*dMdTaZ&
zi)C)v?;WnS87fNNIP&X#_r+r?FK1RWh<)4m{^PW%2372QFQp@vYfqk}DBW`KPLsol
z7jI@vnwoKNLiOsRyG^ReVsiyHyNOH2rd01*>G(6-Q(}(N-pgJK*1N=;icOg06SYEH
zOX|!@=G$4U+v`uvtG+UIpYN<Sn=~WOXg%++nPi&sBrrk3W@;&;s>Gk!@7aF!PVlWe
z@qcph?zm+t?rUOC{d`!EQ1x8GjGecQ^C(MEZdsVcmfl0hnjWWD+VJeTxG`RAk*Mu`
zW)EGT`X5&X^WSzaUe33~WlE$>>F@NwU(fZnX4>tl?eCxYUQw36{w3?~K-Ib7#~Kdl
zy$_kWFZ&JK^SX1=CqqI%*~)|lcGk)p%zv`#H@8UC`kVH1#BM1{Y<PYsJLBt2u_+T4
zpH!Gwv}#g{#Z)7?BbPH|jxV;@CGEIm5~t4+tNXWaJ$iK~U&<niA+C!5VBFRcr#%b0
z#qv`mYM4MV!NtJ9!N9;$%UC~oBclp4b8Y?P8;rWlEVcEMIhf?Yv?h}(m=0vp0@HO&
zCSZCilNVS$6Z2UxeTlgnB)&O;WeyXV@t=*G6U^Whs1XD)Cif`L0@IpGlR4qBKl!MV
zC0LYES(<U_WLaei1?JlNC<Y`T1ZN8{ymdUoIQb#7$mC3A9utrua5)AB24*-1r0@?T
z14Ckoet<V4lL#}+r}dLpDoe}CGJ&N^O3O1-^DtCARF-CRo%~lB5&&{4(u_ru8&zbq
z(M&>dqdtn&D<?Oq*n$1=XI`6A7Xt&s2L=WP4Y(Qxh9!;Pm?jG{Yfa`<<pC@7`OUIQ
zkAZ<fhk=2?2}R*C=E)D4T_#tm@+g2j{Qpz)Zhs~QhHI=03`QslLs%x~vzSc23027b
zPw>wz4hDvG0t^gdC<;HbP3B{lm@KQt0}go|HEAYBfywzS>XQr93>fE4UZ~~@mUyWq
z&D5YcxtB?L@_i<;$!8S#pkaW>!NMLP=aLy17&bC6FbJbqB0IT2U5#y{5=amLw(~^1

-- 
GitLab