Skip to content

Commit

Permalink
feat(service): add project.lock_status endpoint (#2531)
Browse files Browse the repository at this point in the history
  • Loading branch information
m-alisafaee committed Dec 21, 2021
1 parent aa0e592 commit 082e897
Show file tree
Hide file tree
Showing 8 changed files with 185 additions and 10 deletions.
8 changes: 4 additions & 4 deletions renku/cli/service.py
Expand Up @@ -178,11 +178,11 @@ def read_logs(log_file, follow=True, output_all=False):
@click.pass_context
def service(ctx, env):
"""Manage service components."""
try:
import redis # noqa: F401
import rq # noqa: F401
from dotenv import load_dotenv
import redis # noqa: F401
import rq # noqa: F401
from dotenv import load_dotenv

try:
from renku.service.cache.base import BaseCache

BaseCache.cache.ping()
Expand Down
7 changes: 5 additions & 2 deletions renku/service/cache/models/project.py
Expand Up @@ -59,9 +59,12 @@ def abs_path(self):
"""Full path of cached project."""
return CACHE_PROJECTS_PATH / self.user_id / self.project_id / self.owner / self.slug

def read_lock(self):
def read_lock(self, timeout: int = None):
"""Shared read lock on the project."""
return portalocker.Lock(f"{self.abs_path}.lock", flags=portalocker.LOCK_SH, timeout=LOCK_TIMEOUT)
timeout = timeout if timeout is not None else LOCK_TIMEOUT
return portalocker.Lock(
f"{self.abs_path}.lock", flags=portalocker.LOCK_SH | portalocker.LOCK_NB, timeout=timeout
)

def write_lock(self):
"""Exclusive write lock on the project."""
Expand Down
79 changes: 79 additions & 0 deletions renku/service/controllers/project_lock_status.py
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
#
# Copyright 2021 - Swiss Data Science Center (SDSC)
# A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
# Eidgenössische Technische Hochschule Zürich (ETHZ).
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Renku service project lock status controller."""

import portalocker

from renku.core import errors
from renku.service.cache.models.project import Project
from renku.service.controllers.api.abstract import ServiceCtrl
from renku.service.controllers.api.mixins import RenkuOperationMixin
from renku.service.errors import ProjectNotFound
from renku.service.serializers.project import ProjectLockStatusRequest, ProjectLockStatusResponseRPC
from renku.service.views import result_response


class ProjectLockStatusCtrl(ServiceCtrl, RenkuOperationMixin):
"""Controller for project lock status endpoint."""

REQUEST_SERIALIZER = ProjectLockStatusRequest()
RESPONSE_SERIALIZER = ProjectLockStatusResponseRPC()

def __init__(self, cache, user_data, request_data):
"""Construct a project edit controller."""
self.ctx = self.REQUEST_SERIALIZER.load(request_data)

super().__init__(cache, user_data, request_data)

@property
def context(self):
"""Controller operation context."""
return self.ctx

def get_lock_status(self) -> bool:
"""Return True if a project is write-locked."""
if "project_id" in self.context:
try:
project = self.cache.get_project(self.user, self.context["project_id"])
except ProjectNotFound:
return False
elif "git_url" in self.context and "user_id" in self.user_data:
try:
project = Project.get(
(Project.user_id == self.user_data["user_id"]) & (Project.git_url == self.context["git_url"])
)
except ValueError:
return False
else:
raise errors.RenkuException("context does not contain `project_id` or `git_url` or missing `user_id`")

try:
with project.read_lock(timeout=0):
return False
except (portalocker.LockException, portalocker.AlreadyLocked):
return True

def renku_op(self):
"""Renku operation for the controller."""
# NOTE: We leave it empty since it does not execute renku operation.
pass

def to_response(self):
"""Execute controller flow and serialize to service response."""
is_locked = self.get_lock_status()
return result_response(self.RESPONSE_SERIALIZER, data={"locked": is_locked})
9 changes: 7 additions & 2 deletions renku/service/serializers/common.py
Expand Up @@ -32,11 +32,10 @@ class LocalRepositorySchema(Schema):
project_id = fields.String(description="Reference to access the project in the local cache.")


class RemoteRepositorySchema(Schema):
class RemoteRepositoryBaseSchema(Schema):
"""Schema for tracking a remote repository."""

git_url = fields.String(description="Remote git repository url.")
branch = fields.String(description="Remote git branch.")

@validates("git_url")
def validate_git_url(self, value):
Expand All @@ -50,6 +49,12 @@ def validate_git_url(self, value):
return value


class RemoteRepositorySchema(RemoteRepositoryBaseSchema):
"""Schema for tracking a remote repository and branch."""

branch = fields.String(description="Remote git branch.")


class AsyncSchema(Schema):
"""Schema for adding a commit at the end of the operation."""

Expand Down
17 changes: 17 additions & 0 deletions renku/service/serializers/project.py
Expand Up @@ -24,6 +24,7 @@
AsyncSchema,
LocalRepositorySchema,
MigrateSchema,
RemoteRepositoryBaseSchema,
RemoteRepositorySchema,
RenkuSyncSchema,
)
Expand Down Expand Up @@ -78,3 +79,19 @@ class ProjectEditResponseRPC(JsonRPCResponse):
"""RPC schema for a project edit."""

result = fields.Nested(ProjectEditResponse)


class ProjectLockStatusRequest(LocalRepositorySchema, RemoteRepositoryBaseSchema):
"""Project lock status request."""


class ProjectLockStatusResponse(Schema):
"""Project lock status response."""

locked = fields.Boolean(required=True, description="Whether or not a project is locked for writing")


class ProjectLockStatusResponseRPC(JsonRPCResponse):
"""RPC schema for project lock status."""

result = fields.Nested(ProjectLockStatusResponse)
4 changes: 2 additions & 2 deletions renku/service/views/api_versions.py
Expand Up @@ -55,8 +55,8 @@ def add_url_rule(
)


V0_9 = ApiVersion("0.9", is_base_version=True)
V1_0 = ApiVersion("1.0")
V0_9 = ApiVersion("0.9")
V1_0 = ApiVersion("1.0", is_base_version=True)

MINIMUM_VERSION = V0_9
MAXIMUM_VERSION = V1_0
28 changes: 28 additions & 0 deletions renku/service/views/project.py
Expand Up @@ -20,6 +20,7 @@

from renku.service.config import SERVICE_PREFIX
from renku.service.controllers.project_edit import ProjectEditCtrl
from renku.service.controllers.project_lock_status import ProjectLockStatusCtrl
from renku.service.controllers.project_show import ProjectShowCtrl
from renku.service.views.api_versions import V1_0, VersionedBlueprint
from renku.service.views.decorators import accepts_json, handle_common_except, requires_cache, requires_identity
Expand Down Expand Up @@ -82,3 +83,30 @@ def edit_project_view(user_data, cache):
- project
"""
return ProjectEditCtrl(cache, user_data, dict(request.json)).to_response()


@project_blueprint.route("/project.lock_status", methods=["GET"], provide_automatic_options=False, versions=[V1_0])
@handle_common_except
@accepts_json
@requires_cache
@requires_identity
def get_project_lock_status(user_data, cache):
"""
Check whether a project is locked for writing or not.
---
get:
description: Get project write-lock status.
parameters:
- in: query
schema: ProjectLockStatusRequest
responses:
200:
description: Status of the project write-lock.
content:
application/json:
schema: ProjectLockStatusResponseRPC
tags:
- project
"""
return ProjectLockStatusCtrl(cache, user_data, dict(request.args)).to_response()
43 changes: 43 additions & 0 deletions tests/service/views/test_project_views.py
Expand Up @@ -19,6 +19,7 @@
import json
import re

import portalocker
import pytest

from tests.utils import retry_failed
Expand Down Expand Up @@ -110,3 +111,45 @@ def test_remote_edit_view(svc_client, it_remote_repo_url, identity_headers):
assert 200 == response.status_code
assert response.json["result"]["created_at"]
assert response.json["result"]["job_id"]


@pytest.mark.integration
@pytest.mark.service
def test_get_lock_status_unlocked(svc_client_setup):
"""Test getting lock status for an unlocked project."""
svc_client, headers, project_id, _, _ = svc_client_setup

response = svc_client.get("/1.0/project.lock_status", query_string={"project_id": project_id}, headers=headers)

assert 200 == response.status_code
assert {"locked"} == set(response.json["result"].keys())
assert response.json["result"]["locked"] is False


@pytest.mark.integration
@pytest.mark.service
def test_get_lock_status_locked(svc_client_setup):
"""Test getting lock status for a locked project."""
svc_client, headers, project_id, _, repository = svc_client_setup

def mock_lock():
return portalocker.Lock(f"{repository.path}.lock", flags=portalocker.LOCK_EX, timeout=0)

with mock_lock():
response = svc_client.get("/1.0/project.lock_status", query_string={"project_id": project_id}, headers=headers)

assert 200 == response.status_code
assert {"locked"} == set(response.json["result"].keys())
assert response.json["result"]["locked"] is True


@pytest.mark.integration
@pytest.mark.service
@pytest.mark.parametrize("query_params", [{"project_id": "dummy"}, {"git_url": "https://example.com/repo.git"}])
def test_get_lock_status_for_project_not_in_cache(svc_client, identity_headers, query_params):
"""Test getting lock status for an unlocked project which is not cached."""
response = svc_client.get("/1.0/project.lock_status", query_string=query_params, headers=identity_headers)

assert 200 == response.status_code
assert {"locked"} == set(response.json["result"].keys())
assert response.json["result"]["locked"] is False

0 comments on commit 082e897

Please sign in to comment.