From 0f8da718300c0da23030e6f174b6c8987c7d4643 Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Mon, 1 May 2023 21:23:41 +0200 Subject: [PATCH 01/16] Add debug msgs and avoid signing empty url list --- dinamis_sdk/s3.py | 72 ++++++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/dinamis_sdk/s3.py b/dinamis_sdk/s3.py index ff63c65..3fc2f89 100644 --- a/dinamis_sdk/s3.py +++ b/dinamis_sdk/s3.py @@ -447,41 +447,49 @@ def get_signed_urls( for url in urls: signed_url_in_cache = CACHE.get(url) if signed_url_in_cache: - if signed_url_in_cache.ttl() > SIGNED_URL_TTL_MARGIN: + log.debug("URL %s already in cache", url) + ttl = signed_url_in_cache.ttl() + log.debug("URL %s TTL is %s", url, ttl) + if ttl > SIGNED_URL_TTL_MARGIN: + log.debug("Using cache (%s > %s)", ttl, SIGNED_URL_TTL_MARGIN) signed_urls[url] = signed_url_in_cache not_signed_urls = [url for url in urls if url not in signed_urls] log.debug("Already signed URLs:\n %s", signed_urls) - # Refresh the token if there's less than SIGNED_URL_TTL_MARGIN seconds - # remaining, in order to give a small amount of time to do stuff with the - # url - session = requests.Session() - retry = urllib3.util.retry.Retry( - total=retry_total, - backoff_factor=retry_backoff_factor, - ) - adapter = requests.adapters.HTTPAdapter(max_retries=retry) - session.mount("http://", adapter) - session.mount("https://", adapter) - response = session.post( - f"{S3_SIGNING_ENDPOINT}sign_urls", - params={"urls": not_signed_urls}, - headers=headers - ) - response.raise_for_status() - - signed_url_batch = SignedURLBatch(**response.json()) - if not signed_url_batch: - raise ValueError( - f"No signed url batch found in response: {response.json()}" + log.debug("Not signed URLs:\n %s", not_signed_urls) + + if not_signed_urls: + # Refresh the token if there's less than SIGNED_URL_TTL_MARGIN seconds + # remaining, in order to give a small amount of time to do stuff with the + # url + session = requests.Session() + retry = urllib3.util.retry.Retry( + total=retry_total, + backoff_factor=retry_backoff_factor, + ) + adapter = requests.adapters.HTTPAdapter(max_retries=retry) + session.mount("http://", adapter) + session.mount("https://", adapter) + response = session.post( + f"{S3_SIGNING_ENDPOINT}sign_urls", + params={"urls": not_signed_urls}, + headers=headers + ) + response.raise_for_status() + + signed_url_batch = SignedURLBatch(**response.json()) + if not signed_url_batch: + raise ValueError( + f"No signed url batch found in response: {response.json()}" + ) + assert not_signed_urls.keys() == signed_url_batch.keys() + for url, href in signed_url_batch.hrefs.items(): + signed_url = SignedURL(expiry=signed_url_batch.expiry, href=href) + CACHE[url] = signed_url + signed_urls[url] = signed_url + log.debug( + "Got signed urls %s in %s seconds", + signed_urls, + f"{time.time() - start_time:.2f}" ) - for url, href in signed_url_batch.hrefs.items(): - signed_url = SignedURL(expiry=signed_url_batch.expiry, href=href) - CACHE[url] = signed_url - signed_urls[url] = signed_url - log.debug( - "Got signed urls %s in %s seconds", - signed_urls, - f"{time.time() - start_time:.2f}" - ) return signed_urls -- GitLab From 6c8e321671d02cff9cb2d5cb7a39d87615fb8f42 Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Mon, 1 May 2023 21:56:37 +0200 Subject: [PATCH 02/16] Add debug msgs and avoid signing empty url list --- dinamis_sdk/s3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dinamis_sdk/s3.py b/dinamis_sdk/s3.py index 3fc2f89..f8bcdf5 100644 --- a/dinamis_sdk/s3.py +++ b/dinamis_sdk/s3.py @@ -481,7 +481,7 @@ def get_signed_urls( raise ValueError( f"No signed url batch found in response: {response.json()}" ) - assert not_signed_urls.keys() == signed_url_batch.keys() + assert all(key in signed_url_batch for key in not_signed_urls) for url, href in signed_url_batch.hrefs.items(): signed_url = SignedURL(expiry=signed_url_batch.expiry, href=href) CACHE[url] = signed_url -- GitLab From 4d3819e1c0b67c5f96c93fb79a85fc8388b342cd Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Mon, 1 May 2023 22:14:19 +0200 Subject: [PATCH 03/16] Add debug msgs and avoid signing empty url list --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 62b876a..417fc28 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ install_requires = [ setup( name="dinamis-sdk", - version="0.0.7", + version="0.0.7a", description="DINAMIS SDK", python_requires=">=3.8", author="Remi Cresson", -- GitLab From b29540fc87997522118d5325f6668dd837f8a929 Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Mon, 1 May 2023 22:25:35 +0200 Subject: [PATCH 04/16] Add debug msgs and avoid signing empty url list --- dinamis_sdk/s3.py | 3 ++- setup.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/dinamis_sdk/s3.py b/dinamis_sdk/s3.py index f8bcdf5..48f5a83 100644 --- a/dinamis_sdk/s3.py +++ b/dinamis_sdk/s3.py @@ -481,7 +481,8 @@ def get_signed_urls( raise ValueError( f"No signed url batch found in response: {response.json()}" ) - assert all(key in signed_url_batch for key in not_signed_urls) + assert all(key in signed_url_batch.hrefs.items() + for key in not_signed_urls) for url, href in signed_url_batch.hrefs.items(): signed_url = SignedURL(expiry=signed_url_batch.expiry, href=href) CACHE[url] = signed_url diff --git a/setup.py b/setup.py index 417fc28..b9afef7 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ install_requires = [ setup( name="dinamis-sdk", - version="0.0.7a", + version="0.0.7b", description="DINAMIS SDK", python_requires=">=3.8", author="Remi Cresson", -- GitLab From bd2a6d2b4a571d53baaac563186a934e76f4dc68 Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Mon, 1 May 2023 22:36:27 +0200 Subject: [PATCH 05/16] Add debug msgs and avoid signing empty url list --- dinamis_sdk/s3.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dinamis_sdk/s3.py b/dinamis_sdk/s3.py index 48f5a83..d299a6b 100644 --- a/dinamis_sdk/s3.py +++ b/dinamis_sdk/s3.py @@ -481,8 +481,12 @@ def get_signed_urls( raise ValueError( f"No signed url batch found in response: {response.json()}" ) - assert all(key in signed_url_batch.hrefs.items() - for key in not_signed_urls) + if not all(key in signed_url_batch.hrefs.items() + for key in not_signed_urls): + raise ValueError( + f"URLs to sign are {not_signed_urls} but returned signed URLs" + f"are for {signed_url_batch.hrefs.keys()}" + ) for url, href in signed_url_batch.hrefs.items(): signed_url = SignedURL(expiry=signed_url_batch.expiry, href=href) CACHE[url] = signed_url -- GitLab From cae8e9bee63f697dd4f588790b753e17b5209ee4 Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Mon, 1 May 2023 22:36:52 +0200 Subject: [PATCH 06/16] Add debug msgs and avoid signing empty url list --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b9afef7..cb2cb6a 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ install_requires = [ setup( name="dinamis-sdk", - version="0.0.7b", + version="0.0.7c", description="DINAMIS SDK", python_requires=">=3.8", author="Remi Cresson", -- GitLab From be71c270ffb6265f75ff4f83c341526cff7fb7f8 Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Mon, 1 May 2023 22:45:48 +0200 Subject: [PATCH 07/16] Add debug msgs and avoid signing empty url list --- dinamis_sdk/s3.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dinamis_sdk/s3.py b/dinamis_sdk/s3.py index d299a6b..485d538 100644 --- a/dinamis_sdk/s3.py +++ b/dinamis_sdk/s3.py @@ -481,7 +481,7 @@ def get_signed_urls( raise ValueError( f"No signed url batch found in response: {response.json()}" ) - if not all(key in signed_url_batch.hrefs.items() + if not all(key in signed_url_batch.hrefs for key in not_signed_urls): raise ValueError( f"URLs to sign are {not_signed_urls} but returned signed URLs" diff --git a/setup.py b/setup.py index cb2cb6a..8636629 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ install_requires = [ setup( name="dinamis-sdk", - version="0.0.7c", + version="0.0.7d", description="DINAMIS SDK", python_requires=">=3.8", author="Remi Cresson", -- GitLab From f6dc2739772ca30f7eae1a5d25679b5d1c0006fa Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Wed, 3 May 2023 19:58:48 +0200 Subject: [PATCH 08/16] Enh: backoff strategy after post fail --- dinamis_sdk/s3.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dinamis_sdk/s3.py b/dinamis_sdk/s3.py index 485d538..7919630 100644 --- a/dinamis_sdk/s3.py +++ b/dinamis_sdk/s3.py @@ -27,7 +27,7 @@ from .utils import log, SIGNED_URL_TTL_MARGIN, CREDENTIALS S3_STORAGE_DOMAIN = "minio-api-dinamis.apps.okd.crocc.meso.umontpellier.fr" S3_SIGNING_ENDPOINT = \ - "https://s3-signing-dinamis.apps.okd.crocc.meso.umontpellier.fr/" + "https://s3-signing-dinamis.apps.okd.crocc.meso.umontpellier.fr/ping" AssetLike = TypeVar("AssetLike", Asset, Dict[str, Any]) @@ -400,7 +400,7 @@ sign_reference_file = sign_mapping def get_signed_urls( urls: List[str], retry_total: int = 10, - retry_backoff_factor: float = 0.8 + retry_backoff_factor: float = .8 ) -> Dict[str, SignedURL]: """ Get multiple signed URLs. @@ -465,6 +465,8 @@ def get_signed_urls( retry = urllib3.util.retry.Retry( total=retry_total, backoff_factor=retry_backoff_factor, + status_forcelist=[404, 429, 500, 502, 503, 504], + allowed_methods=False, ) adapter = requests.adapters.HTTPAdapter(max_retries=retry) session.mount("http://", adapter) -- GitLab From 1e8d3ea385c203eb979b5a4065d6af02f5d4e54b Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Wed, 3 May 2023 20:02:36 +0200 Subject: [PATCH 09/16] Style: line too long --- dinamis_sdk/s3.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dinamis_sdk/s3.py b/dinamis_sdk/s3.py index 7919630..9a20705 100644 --- a/dinamis_sdk/s3.py +++ b/dinamis_sdk/s3.py @@ -459,8 +459,8 @@ def get_signed_urls( if not_signed_urls: # Refresh the token if there's less than SIGNED_URL_TTL_MARGIN seconds - # remaining, in order to give a small amount of time to do stuff with the - # url + # remaining, in order to give a small amount of time to do stuff with + # the url session = requests.Session() retry = urllib3.util.retry.Retry( total=retry_total, -- GitLab From 71f48a2eccf98bbaeee373d7af63d40157aaff28 Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Wed, 3 May 2023 20:02:49 +0200 Subject: [PATCH 10/16] Imports order --- dinamis_sdk/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dinamis_sdk/utils.py b/dinamis_sdk/utils.py index f8c3078..67c6d79 100644 --- a/dinamis_sdk/utils.py +++ b/dinamis_sdk/utils.py @@ -1,8 +1,8 @@ """Some helpers.""" -import appdirs import json import logging import os +import appdirs from pydantic import BaseModel # pylint: disable = no-name-in-module logging.basicConfig(level=os.environ.get("LOGLEVEL") or "INFO") -- GitLab From 4c6cddb3bf03e7afd31da3f6cbf561ec5e63c11f Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Wed, 3 May 2023 20:03:16 +0200 Subject: [PATCH 11/16] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8636629..3e57b4b 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ install_requires = [ setup( name="dinamis-sdk", - version="0.0.7d", + version="0.0.8", description="DINAMIS SDK", python_requires=">=3.8", author="Remi Cresson", -- GitLab From 567e912f65796519003ca75043ac8e9deba9ef0c Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Wed, 3 May 2023 20:09:19 +0200 Subject: [PATCH 12/16] Debug: remove wrong url --- dinamis_sdk/s3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dinamis_sdk/s3.py b/dinamis_sdk/s3.py index 9a20705..7242f47 100644 --- a/dinamis_sdk/s3.py +++ b/dinamis_sdk/s3.py @@ -27,7 +27,7 @@ from .utils import log, SIGNED_URL_TTL_MARGIN, CREDENTIALS S3_STORAGE_DOMAIN = "minio-api-dinamis.apps.okd.crocc.meso.umontpellier.fr" S3_SIGNING_ENDPOINT = \ - "https://s3-signing-dinamis.apps.okd.crocc.meso.umontpellier.fr/ping" + "https://s3-signing-dinamis.apps.okd.crocc.meso.umontpellier.fr/" AssetLike = TypeVar("AssetLike", Asset, Dict[str, Any]) -- GitLab From c978d272d7703f4345c4bf4c5e4a4edb5c162bd8 Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Thu, 11 May 2023 12:46:15 +0200 Subject: [PATCH 13/16] DOC: collections --- doc/collections.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 doc/collections.md diff --git a/doc/collections.md b/doc/collections.md new file mode 100644 index 0000000..8d9a542 --- /dev/null +++ b/doc/collections.md @@ -0,0 +1,44 @@ +# Collections + +The following collections are currently available in the catalog: +- `spot-6-7-drs`: Spot-6/7 images +- `super-sentinel-2-l2a`: Sentinel-2 images enhanced to 1.5m + + +## Spot-6/7 + +| Location | Dates | +|------------------|-------------------| +| France mainland | From 2017 to 2022 | + +Please read carefully the +[terms of service](https://ids-dinamis.data-terra.org/web/guest/37) related to +the involved products. + +!!! Info + + For legal reasons, only France mainland Spot-6/7 Ortho (Direct Receiving + Station) are available. + +## "Super" Sentinel-2 L2A + +| Location | Dates | +|-----------------------------------|-------------------| +| Selected sites (see figure below) | From 2017 to 2022 | + + +This product consists in synthetic spectral bands (B2, B3, B4 and B8) enhanced +at 1.5m using A.I. with available Spot-6/7 imagery. + +| Site | Bounding box | +|-------------------|------------------------------------------------| +| Vienne-le-château | `[4.886140, 49.171417, 4.995230, 49.238478]` | +| Montpellier | `[3.696201, 43.547450, 4.036414, 43.754317]` | +| Dune du pilat | `[-1.286111, 44.498021, -1.124742, 44.629694]` | +| Lac de la gimone | `[0.602124, 43.293268, 0.728970, 43.385851]` | +| Lévignacq | `[-1.259343, 43.941352, -1.088786, 44.065011]` | + +<div align="center"> +<img src="https://gitlab.irstea.fr/dinamis/dinamis-sdk/uploads/b1ec1a00ead8b5f7d76a92c159a489f6/super_s2_roi.jpg"> +<p>Figure: super-sentinel-2 is currently produced on 4 small ROIs</p> +</div> \ No newline at end of file -- GitLab From 053a63af3026a833249810971470b91cadc81625 Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Thu, 11 May 2023 12:46:40 +0200 Subject: [PATCH 14/16] DOC: specify spot-6-7-drs collection in examples --- dinamis_sdk/examples/pyotb_ndvi_loss.py | 12 +++++++----- dinamis_sdk/examples/pyotb_toa_mosaic.py | 9 ++++++--- doc/processing_examples.md | 21 +++++++++++++-------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/dinamis_sdk/examples/pyotb_ndvi_loss.py b/dinamis_sdk/examples/pyotb_ndvi_loss.py index 421199e..bd5afd6 100644 --- a/dinamis_sdk/examples/pyotb_ndvi_loss.py +++ b/dinamis_sdk/examples/pyotb_ndvi_loss.py @@ -8,12 +8,14 @@ api = Client.open( modifier=sign_inplace ) -bbox = [4, 42.99, 5, 44.05] - def mosa(year): """Return a pyotb application that perform a mosaic.""" - res = api.search(bbox=bbox, datetime=[f'{year}-01-01', f'{year}-12-25']) + res = api.search( + bbox=[4, 42.99, 5, 44.05], + datetime=[f"{year}-01-01", f"{year}-12-25"], + collections=["spot-6-7-drs"] + ) urls = [f"/vsicurl/{r.assets['src_xs'].href}" for r in res.items()] return pyotb.Mosaic({"il": urls}) @@ -23,7 +25,7 @@ def ndvi(xs): return pyotb.BandMath({"il": [xs], "exp": "(im1b4-im1b1)/(im1b4+im1b1)"}) -ndvi_22 = ndvi(mosa('2022')) -ndvi_21 = ndvi(mosa('2021')) +ndvi_22 = ndvi(mosa("2022")) +ndvi_21 = ndvi(mosa("2021")) delta_ndvi = ndvi_22 - pyotb.Superimpose({"inr": ndvi_22, "inm": ndvi_21}) delta_ndvi.write("ndvi_loss.tif?&box=5000:5000:4096:4096") diff --git a/dinamis_sdk/examples/pyotb_toa_mosaic.py b/dinamis_sdk/examples/pyotb_toa_mosaic.py index b43ed5b..3a51911 100644 --- a/dinamis_sdk/examples/pyotb_toa_mosaic.py +++ b/dinamis_sdk/examples/pyotb_toa_mosaic.py @@ -8,9 +8,12 @@ api = Client.open( modifier=sign_inplace ) -year = 2022 -bbox = [4, 42.99, 5, 44.05] -res = api.search(bbox=bbox, datetime=[f'{year}-01-01', f'{year}-12-25']) +res = api.search( + bbox=[4, 42.99, 5, 44.05], + datetime=["2022-01-01", "2022-12-25"], + collections=["spot-6-7-drs"] +) + urls = [f"/vsicurl/{r.assets['src_xs'].href}" for r in res.items()] toa_images = [pyotb.OpticalCalibration({"in": url}) for url in urls] mosa = pyotb.Mosaic({"il": toa_images}) diff --git a/doc/processing_examples.md b/doc/processing_examples.md index fdde33e..e2c719d 100644 --- a/doc/processing_examples.md +++ b/doc/processing_examples.md @@ -29,9 +29,11 @@ dinamis-sdk/-/tree/main/dinamis_sdk/examples/pyotb_toa_mosaic.py){ .md-button } We first perform a STAC search over the camargue area in the year 2022: ```python -year = 2022 -bbox = [4, 42.99, 5, 44.05] -res = api.search(bbox=bbox, datetime=[f'{year}-01-01', f'{year}-12-25']) +res = api.search( + bbox=[4, 42.99, 5, 44.05], + datetime=["2022-01-01", "2022-12-25"], + collections=["spot-6-7-drs"] +) ``` Then, we append the */vsicurl/* suffix to XS images assets URLs to tell GDAL @@ -89,10 +91,13 @@ Not lets create a function to grab some images over a given bounding box, and return the resulting mosaic: ```python -bbox = [4, 42.99, 5, 44.05] - def mosa(year): - res = api.search(bbox=bbox, datetime=[f'{year}-01-01', f'{year}-12-25']) + res = api.search( + bbox=[4, 42.99, 5, 44.05], + datetime=[f"{year}-01-01", f"{year}-12-25"], + collections=["spot-6-7-drs"] + ) + urls = [f"/vsicurl/{r.assets['src_xs'].href}" for r in res.items()] return pyotb.Mosaic({"il": urls}) ``` @@ -115,8 +120,8 @@ def ndvi(xs): We can now compute two NDVI mosaics for each year: ```python -ndvi_22 = ndvi(mosa('2022')) -ndvi_21 = ndvi(mosa('2021')) +ndvi_22 = ndvi(mosa("2022")) +ndvi_21 = ndvi(mosa("2021")) ``` One last step consist in interpolating the values of the second NDVI mosaic -- GitLab From 0752e3a0f778d9ced9bbe387f49258c0a3058788 Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Thu, 11 May 2023 12:47:05 +0200 Subject: [PATCH 15/16] DOC: move terms of service in collections section --- doc/index.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/doc/index.md b/doc/index.md index a3023ae..5cc28bd 100644 --- a/doc/index.md +++ b/doc/index.md @@ -57,17 +57,6 @@ expiry. You can open issues or merge requests at [INRAE's gitlab](https://gitlab.irstea.fr/dinamis/dinamis-sdk). -## Terms of service - -Please read carefully the -[terms of service](https://ids-dinamis.data-terra.org/web/guest/37) related to -the involved products. - -!!! Info - - For legal reasons, only France mainland Spot-6/7 Ortho (Direct Receiving - Station) are available. - ## Contact Rémi Cresson at INRAE dot fr -- GitLab From 39dcf6ca3aa42935ac16f2108b852e80711ce00f Mon Sep 17 00:00:00 2001 From: Remi Cresson <remi.cresson@inrae.fr> Date: Thu, 11 May 2023 12:47:24 +0200 Subject: [PATCH 16/16] DOC: update index --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 9d8a8d1..fc260ed 100755 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,6 +25,7 @@ plugins: nav: - Home: index.md +- Collections: collections.md - Credentials: credentials.md - Advanced use: advanced.md - Additional resources: additional_resources.md -- GitLab