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