Skip to content

Commit 082e897

Browse files
feat(service): add project.lock_status endpoint (#2531)
1 parent aa0e592 commit 082e897

File tree

8 files changed

+185
-10
lines changed

8 files changed

+185
-10
lines changed

renku/cli/service.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,11 @@ def read_logs(log_file, follow=True, output_all=False):
178178
@click.pass_context
179179
def service(ctx, env):
180180
"""Manage service components."""
181-
try:
182-
import redis # noqa: F401
183-
import rq # noqa: F401
184-
from dotenv import load_dotenv
181+
import redis # noqa: F401
182+
import rq # noqa: F401
183+
from dotenv import load_dotenv
185184

185+
try:
186186
from renku.service.cache.base import BaseCache
187187

188188
BaseCache.cache.ping()

renku/service/cache/models/project.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,12 @@ def abs_path(self):
5959
"""Full path of cached project."""
6060
return CACHE_PROJECTS_PATH / self.user_id / self.project_id / self.owner / self.slug
6161

62-
def read_lock(self):
62+
def read_lock(self, timeout: int = None):
6363
"""Shared read lock on the project."""
64-
return portalocker.Lock(f"{self.abs_path}.lock", flags=portalocker.LOCK_SH, timeout=LOCK_TIMEOUT)
64+
timeout = timeout if timeout is not None else LOCK_TIMEOUT
65+
return portalocker.Lock(
66+
f"{self.abs_path}.lock", flags=portalocker.LOCK_SH | portalocker.LOCK_NB, timeout=timeout
67+
)
6568

6669
def write_lock(self):
6770
"""Exclusive write lock on the project."""
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright 2021 - Swiss Data Science Center (SDSC)
4+
# A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
5+
# Eidgenössische Technische Hochschule Zürich (ETHZ).
6+
#
7+
# Licensed under the Apache License, Version 2.0 (the "License");
8+
# you may not use this file except in compliance with the License.
9+
# You may obtain a copy of the License at
10+
#
11+
# http://www.apache.org/licenses/LICENSE-2.0
12+
#
13+
# Unless required by applicable law or agreed to in writing, software
14+
# distributed under the License is distributed on an "AS IS" BASIS,
15+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
# See the License for the specific language governing permissions and
17+
# limitations under the License.
18+
"""Renku service project lock status controller."""
19+
20+
import portalocker
21+
22+
from renku.core import errors
23+
from renku.service.cache.models.project import Project
24+
from renku.service.controllers.api.abstract import ServiceCtrl
25+
from renku.service.controllers.api.mixins import RenkuOperationMixin
26+
from renku.service.errors import ProjectNotFound
27+
from renku.service.serializers.project import ProjectLockStatusRequest, ProjectLockStatusResponseRPC
28+
from renku.service.views import result_response
29+
30+
31+
class ProjectLockStatusCtrl(ServiceCtrl, RenkuOperationMixin):
32+
"""Controller for project lock status endpoint."""
33+
34+
REQUEST_SERIALIZER = ProjectLockStatusRequest()
35+
RESPONSE_SERIALIZER = ProjectLockStatusResponseRPC()
36+
37+
def __init__(self, cache, user_data, request_data):
38+
"""Construct a project edit controller."""
39+
self.ctx = self.REQUEST_SERIALIZER.load(request_data)
40+
41+
super().__init__(cache, user_data, request_data)
42+
43+
@property
44+
def context(self):
45+
"""Controller operation context."""
46+
return self.ctx
47+
48+
def get_lock_status(self) -> bool:
49+
"""Return True if a project is write-locked."""
50+
if "project_id" in self.context:
51+
try:
52+
project = self.cache.get_project(self.user, self.context["project_id"])
53+
except ProjectNotFound:
54+
return False
55+
elif "git_url" in self.context and "user_id" in self.user_data:
56+
try:
57+
project = Project.get(
58+
(Project.user_id == self.user_data["user_id"]) & (Project.git_url == self.context["git_url"])
59+
)
60+
except ValueError:
61+
return False
62+
else:
63+
raise errors.RenkuException("context does not contain `project_id` or `git_url` or missing `user_id`")
64+
65+
try:
66+
with project.read_lock(timeout=0):
67+
return False
68+
except (portalocker.LockException, portalocker.AlreadyLocked):
69+
return True
70+
71+
def renku_op(self):
72+
"""Renku operation for the controller."""
73+
# NOTE: We leave it empty since it does not execute renku operation.
74+
pass
75+
76+
def to_response(self):
77+
"""Execute controller flow and serialize to service response."""
78+
is_locked = self.get_lock_status()
79+
return result_response(self.RESPONSE_SERIALIZER, data={"locked": is_locked})

renku/service/serializers/common.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,10 @@ class LocalRepositorySchema(Schema):
3232
project_id = fields.String(description="Reference to access the project in the local cache.")
3333

3434

35-
class RemoteRepositorySchema(Schema):
35+
class RemoteRepositoryBaseSchema(Schema):
3636
"""Schema for tracking a remote repository."""
3737

3838
git_url = fields.String(description="Remote git repository url.")
39-
branch = fields.String(description="Remote git branch.")
4039

4140
@validates("git_url")
4241
def validate_git_url(self, value):
@@ -50,6 +49,12 @@ def validate_git_url(self, value):
5049
return value
5150

5251

52+
class RemoteRepositorySchema(RemoteRepositoryBaseSchema):
53+
"""Schema for tracking a remote repository and branch."""
54+
55+
branch = fields.String(description="Remote git branch.")
56+
57+
5358
class AsyncSchema(Schema):
5459
"""Schema for adding a commit at the end of the operation."""
5560

renku/service/serializers/project.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
AsyncSchema,
2525
LocalRepositorySchema,
2626
MigrateSchema,
27+
RemoteRepositoryBaseSchema,
2728
RemoteRepositorySchema,
2829
RenkuSyncSchema,
2930
)
@@ -78,3 +79,19 @@ class ProjectEditResponseRPC(JsonRPCResponse):
7879
"""RPC schema for a project edit."""
7980

8081
result = fields.Nested(ProjectEditResponse)
82+
83+
84+
class ProjectLockStatusRequest(LocalRepositorySchema, RemoteRepositoryBaseSchema):
85+
"""Project lock status request."""
86+
87+
88+
class ProjectLockStatusResponse(Schema):
89+
"""Project lock status response."""
90+
91+
locked = fields.Boolean(required=True, description="Whether or not a project is locked for writing")
92+
93+
94+
class ProjectLockStatusResponseRPC(JsonRPCResponse):
95+
"""RPC schema for project lock status."""
96+
97+
result = fields.Nested(ProjectLockStatusResponse)

renku/service/views/api_versions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ def add_url_rule(
5555
)
5656

5757

58-
V0_9 = ApiVersion("0.9", is_base_version=True)
59-
V1_0 = ApiVersion("1.0")
58+
V0_9 = ApiVersion("0.9")
59+
V1_0 = ApiVersion("1.0", is_base_version=True)
6060

6161
MINIMUM_VERSION = V0_9
6262
MAXIMUM_VERSION = V1_0

renku/service/views/project.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from renku.service.config import SERVICE_PREFIX
2222
from renku.service.controllers.project_edit import ProjectEditCtrl
23+
from renku.service.controllers.project_lock_status import ProjectLockStatusCtrl
2324
from renku.service.controllers.project_show import ProjectShowCtrl
2425
from renku.service.views.api_versions import V1_0, VersionedBlueprint
2526
from renku.service.views.decorators import accepts_json, handle_common_except, requires_cache, requires_identity
@@ -82,3 +83,30 @@ def edit_project_view(user_data, cache):
8283
- project
8384
"""
8485
return ProjectEditCtrl(cache, user_data, dict(request.json)).to_response()
86+
87+
88+
@project_blueprint.route("/project.lock_status", methods=["GET"], provide_automatic_options=False, versions=[V1_0])
89+
@handle_common_except
90+
@accepts_json
91+
@requires_cache
92+
@requires_identity
93+
def get_project_lock_status(user_data, cache):
94+
"""
95+
Check whether a project is locked for writing or not.
96+
97+
---
98+
get:
99+
description: Get project write-lock status.
100+
parameters:
101+
- in: query
102+
schema: ProjectLockStatusRequest
103+
responses:
104+
200:
105+
description: Status of the project write-lock.
106+
content:
107+
application/json:
108+
schema: ProjectLockStatusResponseRPC
109+
tags:
110+
- project
111+
"""
112+
return ProjectLockStatusCtrl(cache, user_data, dict(request.args)).to_response()

tests/service/views/test_project_views.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import json
2020
import re
2121

22+
import portalocker
2223
import pytest
2324

2425
from tests.utils import retry_failed
@@ -110,3 +111,45 @@ def test_remote_edit_view(svc_client, it_remote_repo_url, identity_headers):
110111
assert 200 == response.status_code
111112
assert response.json["result"]["created_at"]
112113
assert response.json["result"]["job_id"]
114+
115+
116+
@pytest.mark.integration
117+
@pytest.mark.service
118+
def test_get_lock_status_unlocked(svc_client_setup):
119+
"""Test getting lock status for an unlocked project."""
120+
svc_client, headers, project_id, _, _ = svc_client_setup
121+
122+
response = svc_client.get("/1.0/project.lock_status", query_string={"project_id": project_id}, headers=headers)
123+
124+
assert 200 == response.status_code
125+
assert {"locked"} == set(response.json["result"].keys())
126+
assert response.json["result"]["locked"] is False
127+
128+
129+
@pytest.mark.integration
130+
@pytest.mark.service
131+
def test_get_lock_status_locked(svc_client_setup):
132+
"""Test getting lock status for a locked project."""
133+
svc_client, headers, project_id, _, repository = svc_client_setup
134+
135+
def mock_lock():
136+
return portalocker.Lock(f"{repository.path}.lock", flags=portalocker.LOCK_EX, timeout=0)
137+
138+
with mock_lock():
139+
response = svc_client.get("/1.0/project.lock_status", query_string={"project_id": project_id}, headers=headers)
140+
141+
assert 200 == response.status_code
142+
assert {"locked"} == set(response.json["result"].keys())
143+
assert response.json["result"]["locked"] is True
144+
145+
146+
@pytest.mark.integration
147+
@pytest.mark.service
148+
@pytest.mark.parametrize("query_params", [{"project_id": "dummy"}, {"git_url": "https://example.com/repo.git"}])
149+
def test_get_lock_status_for_project_not_in_cache(svc_client, identity_headers, query_params):
150+
"""Test getting lock status for an unlocked project which is not cached."""
151+
response = svc_client.get("/1.0/project.lock_status", query_string=query_params, headers=identity_headers)
152+
153+
assert 200 == response.status_code
154+
assert {"locked"} == set(response.json["result"].keys())
155+
assert response.json["result"]["locked"] is False

0 commit comments

Comments
 (0)