diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 425668c..17f3db5 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -24,12 +24,6 @@ jobs: python -m pip install --upgrade pip pip install -r server/requirements.txt - - name: Unpack certificates - run: | - sudo apt-get update - sudo apt-get install -y unrar - unrar x distribution/certs/certs.rar distribution/certs/ - - name: Create .env file run: | echo "RUN_TESTS=true" > .env diff --git a/README.md b/README.md index ef88e6a..63c01ec 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ ## Getting started -1) Go to `/distribution/certs` and extract `certs.rar`. +1) You can optionally regenerate the certificate in `/distribution/certs`. 2) Build and run: `docker compose up--build`. @@ -71,14 +71,15 @@ In case you still have issues, create a file `/etc/docker/daemon.json` and write Push an image into the registry: ```sh -docker tag {image-name} localhost:5000/{image-name} -docker push localhost:5000/{image-name} +docker tag {image-name}[:{tag}] localhost:5000/{image-name}[:{tag}] +docker push localhost:5000/{image-name}[:{tag}] ``` +If no `:tag` is provided, latest is used by default. Pull an image from the registry: ```sh -docker pull localhost:5000/{image-name} +docker pull localhost:5000/{image-name}[:{tag}] ``` Log out: diff --git a/client/src/api/tags.api.ts b/client/src/api/tags.api.ts index 9eccdbd..e7ec9c1 100644 --- a/client/src/api/tags.api.ts +++ b/client/src/api/tags.api.ts @@ -4,11 +4,20 @@ import { PaginationDTO, PaginationParams } from "../util/pagination"; export interface TagDTO { id: number, - name: string | null, + name: string, last_push: string, // Encoded Date() object. last_pushed_by_username: string, } +export interface DeleteTagDTO { + repo_id: number, + tag_name: string +} + +export interface DeleteTagResponseDTO { + message: string +} + export class TagsService { static async GetAllTagsOfRepo(repo_canonical_name: string): Promise> { return await axiosInstance.get(`/tags/?repo_name=${repo_canonical_name}`); @@ -21,4 +30,9 @@ export class TagsService { return await axiosInstance.get(`/tags/filter?${queryParams.toString()}`); } + + + static async DeleteTag(dto: DeleteTagDTO): Promise> { + return await axiosInstance.delete(`/registry/tag`, { data: dto }); + } } \ No newline at end of file diff --git a/client/src/components/RepoTags.tsx b/client/src/components/RepoTags.tsx index 29b5cc9..78c6358 100644 --- a/client/src/components/RepoTags.tsx +++ b/client/src/components/RepoTags.tsx @@ -6,7 +6,9 @@ import { PaginationParams } from '../util/pagination'; import { AxiosError } from 'axios'; import { formatDistanceToNow } from 'date-fns'; import ResponsivePagination from 'react-responsive-pagination'; -import { Link, NavLink } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; +import { ToastType, useToastStore } from '../util/toastStore'; + export const RepoTags: React.FC<{ isActive: boolean, repo: RepoExtDTO }> = (props) => { const orderByOptions = [ @@ -20,6 +22,8 @@ export const RepoTags: React.FC<{ isActive: boolean, repo: RepoExtDTO }> = (prop const [itemsPerPage, setItemsPerPage] = useState(10); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); + const addToast = useToastStore((state) => state.addToast); + const [deletingTag, setDeletingTag] = useState(null); useEffect(() => { if (props.isActive) { @@ -60,6 +64,24 @@ export const RepoTags: React.FC<{ isActive: boolean, repo: RepoExtDTO }> = (prop }); } + const handleDeleteTag = async (tag: TagDTO) => { + setDeletingTag(tag.name); + + TagsService.DeleteTag({ repo_id: props.repo.id, tag_name: tag.name }) + .then((res) => { + addToast(res.data.message, ToastType.success); + setTags(currentTags => currentTags.filter(t => t.name !== tag.name)); + }) + .catch((err: AxiosError) => { + const data = (err.response?.data ?? {}) as any; + const msg = typeof data.detail === 'string' ? data.detail : data.detail?.message; + addToast(msg, ToastType.error); + }) + .finally(() => { + setDeletingTag(null); + }); + }; + return (
{/* Error? */} @@ -104,29 +126,38 @@ export const RepoTags: React.FC<{ isActive: boolean, repo: RepoExtDTO }> = (prop tags.map((tag) => ( - - {/* Tag title */} - Tag + +
+ {/* Tag title */} + Tag - {/* Tag name */} - {tag.name ? + {/* Tag name */} {tag.name} - : - (Base image generated by Distribution) - } - {/* Last pushed */} - - Last pushed {new Date(tag.last_push).toLocaleString()}}> - {formatDistanceToNow(new Date(tag.last_push), { addSuffix: true })} - by {tag.last_pushed_by_username} - - - {/* ... */} - - + {/* Last pushed */} + + Last pushed {new Date(tag.last_push).toLocaleString()}}> + {formatDistanceToNow(new Date(tag.last_push), { addSuffix: true })} + by {tag.last_pushed_by_username} + + + {/* ... */} + + +
+ + {/* Delete tag */} + {/* We can reuse `can_update` as `can_delete_tag` since the access-control logic is identical. */} + {props.repo?.can_update && tag.name && + + }
diff --git a/compose.yaml b/compose.yaml index b0b9df0..eb05ae5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -25,6 +25,9 @@ services: JWT_ALGORITHM: HS256 ELASTICSEARCH_HOST: elasticsearch ELASTICSEARCH_PORT: 9200 + DISTRIBUTION_HOST: distribution + DISTRIBUTION_PORT: 5000 + PEM_CERT_PATH: /code/certs/cert.pem # To run tests, do `RUN_TESTS=true docker compose up`. # However, I've been having issues with this on WSL, @@ -42,7 +45,7 @@ services: volumes: - backend_cfg:/code/volume-server-cfg/ - ./images:/code/images - - "./distribution/certs:/mnt/local/certs" + - ./distribution/certs:/code/certs - ./logs:/code/logs db: @@ -94,6 +97,10 @@ services: volumes: - "./distribution/config.yaml:/etc/docker/registry/config.yml" - "./distribution/certs:/mnt/local/certs" + - "./distribution/entrypoint.sh:/entrypoint.sh" + entrypoint: ["/bin/sh", "/entrypoint.sh"] + environment: + RUN_REGISTRY_GC: "true" elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:8.6.2 diff --git a/distribution/certs/README.md b/distribution/certs/README.md index 74c88db..c4a8d64 100644 --- a/distribution/certs/README.md +++ b/distribution/certs/README.md @@ -7,9 +7,11 @@ Note: If you are on Windows, execute these commands under git bash (or any syste 1. Generate a self-signed certificate: ```sh -openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout private_key.pem -out cert.pem +openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout private_key.pem -out cert.pem -addext "subjectAltName = DNS:distribution" ``` +Note: You can omit the `-addext "subjectAltName = DNS:distribution"` option, but in that case, make sure to set the Common Name (`CN`) to `distribution`. This is necessary to enable proper SSL communication between the backend and the distribution container. + 2. We'll also need the base64 certificate payload (that is, without the headers). For that purpose, we can convert `.pem` into `.der` and then convert the binary `.der` into base64: ```sh diff --git a/distribution/certs/certs.rar b/distribution/certs/certs.rar deleted file mode 100644 index 2e4acac..0000000 Binary files a/distribution/certs/certs.rar and /dev/null differ diff --git a/distribution/certs/certs.zip b/distribution/certs/certs.zip new file mode 100644 index 0000000..4c96bb4 Binary files /dev/null and b/distribution/certs/certs.zip differ diff --git a/distribution/config.yaml b/distribution/config.yaml index 6df9b53..1906495 100644 --- a/distribution/config.yaml +++ b/distribution/config.yaml @@ -4,6 +4,8 @@ log: storage: filesystem: rootdirectory: /var/lib/registry + delete: + enabled: true http: addr: 0.0.0.0:5000 tls: @@ -18,7 +20,7 @@ auth: # In this case, the issuer is the server host. issuer: localhost:8000 # The service is the docker registry host. - service: localhost:5000 + service: distribution:5000 # You must specify the root certificate bundle. For self-signed # certificates, that's just the certificate file itself. rootcertbundle: /mnt/local/certs/cert.pem diff --git a/distribution/entrypoint.sh b/distribution/entrypoint.sh new file mode 100644 index 0000000..80e92ef --- /dev/null +++ b/distribution/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +REGISTRY_STORAGE_PATH="/var/lib/registry/docker/registry/v2/repositories" + +if [ "$RUN_REGISTRY_GC" = "true" ]; then + if [ -d "$REGISTRY_STORAGE_PATH" ]; then + echo "Running Distribution garbage collection..." + registry garbage-collect /etc/docker/registry/config.yml --delete-untagged + else + echo "Skipping garbage collection: No images have ever been pushed by any user." + fi +else + echo "Skipping garbage collection: RUN_REGISTRY_GC is not true." +fi + +echo "Starting Distribution..." +exec registry serve /etc/docker/registry/config.yml \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile index bfbc8ed..28bd3f4 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -4,12 +4,16 @@ WORKDIR /code COPY ./requirements.txt /code/requirements.txt +RUN apk add 7zip --no-cache + RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt COPY ./app /code/app RUN mkdir -p /code/logs +RUN mkdir -p /code/certs + COPY ./entrypoint.sh /code/entrypoint.sh RUN ls -l /code RUN chmod +x /code/entrypoint.sh diff --git a/server/app/api/access_control/access_control_service.py b/server/app/api/access_control/access_control_service.py index 9fb3429..48488ea 100644 --- a/server/app/api/access_control/access_control_service.py +++ b/server/app/api/access_control/access_control_service.py @@ -162,5 +162,12 @@ def has_star_access(self, user_id: int | None, repo_id: int) -> bool: return True + # A user must satisfy one of the following conditions: + # - Be the owner of the repository. + # - Belong to a team that has either 'admin' or 'read_write' permissions. + # The same conditions apply to the `has_write_access` method. + def has_delete_tag_access(self, user_id: int | None, repo_id: int) -> bool: + return self.has_write_access(user_id, repo_id) + def get_access_control_service(session: Session = Depends(get_database)) -> AccessControlService: return AccessControlService(session) \ No newline at end of file diff --git a/server/app/api/config/auth.py b/server/app/api/config/auth.py index 3316dd8..381905f 100644 --- a/server/app/api/config/auth.py +++ b/server/app/api/config/auth.py @@ -21,7 +21,7 @@ from typing import Annotated from functools import wraps from typing import List, Callable - +import inspect if os.getenv('mocker_hub_TEST_ENV') is not None: print("[!] Detected environment variable mocker_hub_TEST_ENV -> setting up unsafe JWT envirnoment") @@ -163,12 +163,15 @@ def pre_authorize(roles: List[str] | List[UserRole] | None, ignore_password_chan def decorator(func: Callable): @wraps(func) - def wrapper(*args, **kwargs): + async def wrapper(*args, **kwargs): jwt_dep = kwargs.get('jwt') if not jwt_dep: # TODO: Change error message in production. raise Exception("Cannot perform authorization without a JWT. Your router endpoint function MUST have a parameter named `jwt: JWTDep`") validate_jwt_or_raise_exceptions(jwt_dep, roles, ignore_password_change_requirement) - return func(*args, **kwargs) + if inspect.iscoroutinefunction(func): + return await func(*args, **kwargs) + else: + return func(*args, **kwargs) return wrapper return decorator diff --git a/server/app/api/config/exception_handler.py b/server/app/api/config/exception_handler.py index 829c122..cf94040 100644 --- a/server/app/api/config/exception_handler.py +++ b/server/app/api/config/exception_handler.py @@ -46,6 +46,21 @@ def __init__(self, msg: str): def __str__(self): return self.message + +class RegistryException(Exception): + def __init__(self, status_code: int, message: str): + self.status_code = status_code + self.message = f"Registry error with status_code {status_code} and message: \n{message}" + + def __str__(self): + return self.message + +class ConflictException(Exception): + def __init__(self, msg: str): + self.message = f"{msg}" + + def __str__(self): + return self.message def register_exception_handler(app: FastAPI): @app.exception_handler(NotFoundException) @@ -62,4 +77,12 @@ def _ValidationError(r: Request, e: RequestValidationError): @app.exception_handler(sqlalchemy.exc.IntegrityError) def _IntegrityError(r: Request, e: sqlalchemy.exc.IntegrityError): - raise HTTPException(400, detail={"message": e._message()}) \ No newline at end of file + raise HTTPException(400, detail={"message": e._message()}) + + @app.exception_handler(RegistryException) + def _RegistryException(r: Request, e: RegistryException): + raise HTTPException(status_code=e.status_code, detail={"message": e.message}) + + @app.exception_handler(ConflictException) + def _ConflictException(r: Request, e: ConflictException): + raise HTTPException(409, detail={"message": str(e)}) \ No newline at end of file diff --git a/server/app/api/config/initialize.py b/server/app/api/config/initialize.py index 956dbeb..f808180 100644 --- a/server/app/api/config/initialize.py +++ b/server/app/api/config/initialize.py @@ -1,6 +1,7 @@ import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from httpx import AsyncClient from sqlmodel import SQLModel, select from app.api.config.database import engine, get_database from app.api.user.user_model import User, UserRole @@ -10,6 +11,7 @@ from app.api.org.org_service import OrganizationService from app.api.repo.repo_dto import RepositoryCreateDTO from app.api.org.org_dto import OrganizationCreateDTO +from app.api.registry.registry_client import RegistryClient def init_create_tables(): SQLModel.metadata.create_all(engine) @@ -27,6 +29,12 @@ def configure_cors(app: FastAPI): allow_headers=["*"], ) +def init_registry_client(): + host = os.environ["DISTRIBUTION_HOST"] + port = os.environ["DISTRIBUTION_PORT"] + cert = os.environ["PEM_CERT_PATH"] + return RegistryClient(AsyncClient(verify=cert), host, port, True) + def init_superadmin(): session = next(get_database()) service = UserService(session) diff --git a/server/app/api/main.py b/server/app/api/main.py index 9e00037..8092c3d 100644 --- a/server/app/api/main.py +++ b/server/app/api/main.py @@ -2,7 +2,9 @@ import os from fastapi import APIRouter, FastAPI from contextlib import asynccontextmanager -from app.api.config.initialize import init_create_tables, configure_cors, init_dummy_data, init_superadmin + +from httpx import AsyncClient +from app.api.config.initialize import init_create_tables, configure_cors, init_dummy_data, init_registry_client, init_superadmin from app.api.config.exception_handler import register_exception_handler import app.api.events import app.api.events.event_controller @@ -22,9 +24,10 @@ the_router.include_router(app.api.team.team_controller.router) the_router.include_router(app.api.events.event_controller.router) the_router.include_router(app.api.tags.tag_controller.router) +the_router.include_router(app.api.registry.registry_controller.external_router) internal_registry_router = APIRouter() -internal_registry_router.include_router(app.api.registry.registry_controller.router) +internal_registry_router.include_router(app.api.registry.registry_controller.internal_router) # TODO: Remove in production, this is for development purposes only. # This is slightly easier to deal with than environment variables. @@ -37,8 +40,10 @@ async def lifespan(app: FastAPI): init_create_tables() init_cache() init_superadmin() + app.registry_client = init_registry_client() asyncio.create_task(try_to_init_elasticsearch()) yield + await app.registry_client.client.aclose() app = FastAPI(lifespan=lifespan) app.include_router(the_router, prefix="/api/v1") diff --git a/server/app/api/registry/registry_client.py b/server/app/api/registry/registry_client.py new file mode 100644 index 0000000..b1eb6a6 --- /dev/null +++ b/server/app/api/registry/registry_client.py @@ -0,0 +1,56 @@ +from typing import Literal +from fastapi import Request, Response +from httpx import AsyncClient +from app.api.config.exception_handler import RegistryException +from app.api.config.logutil import LOGGER + +class RegistryClient: + + def __init__(self, client: AsyncClient, host: str, port: str, secured: bool = False): + self.client = client + self.host = host + self.port = port + self.secured = secured + + def _get_scheme(self) -> str: + return "https://" if self.secured else "http://" + + def _create_manifest_path(self, repo_name: str, digest: str | None = None, tag_name: str | None = None) -> str: + host = self.host + port = self.port + scheme = self._get_scheme() + ref = tag_name if digest is None else digest + return f"{scheme}{host}:{port}/v2/{repo_name}/manifests/{ref}" + + async def _request(self, method: Literal["GET", "DELETE"], url: str, token: str, **kwargs) -> Response: + headers = kwargs.pop('headers', {}) + headers["Authorization"] = f"Bearer {token}" + + try: + response = await self.client.request(method, url, headers=headers) + except Exception as e: + LOGGER.error(f"Error when calling Distribution (method={method} url={url} status=502): \n{e}") + raise RegistryException(502, str(e)) + + if response.status_code not in (200, 202, 404): + body = response.text + LOGGER.error(f"Unexpected HTTP response when calling Distribution (method={method} url={url} status={response.status_code}): \n{body}") + raise RegistryException(response.status_code, body) + + return response + + async def get_manifest(self, repo_name: str, tag_name: str, token: str) -> Response: + url = self._create_manifest_path(repo_name, tag_name) + headers = { + "Accept": "application/vnd.docker.distribution.manifest.v2+json, " + "application/vnd.oci.image.index.v1+json, " + "application/vnd.oci.image.manifest.v1+json" + } + return await self._request("GET", url, token, headers=headers) + + async def delete_manifest(self, repo_name: str, digest: str, token: str) -> Response: + url = self._create_manifest_path(repo_name, digest) + return await self._request("DELETE", url, token) + +def get_registry_client(request: Request = Request) -> RegistryClient: + return request.app.registry_client diff --git a/server/app/api/registry/registry_controller.py b/server/app/api/registry/registry_controller.py index 89ef67b..8b3e1b3 100644 --- a/server/app/api/registry/registry_controller.py +++ b/server/app/api/registry/registry_controller.py @@ -4,10 +4,19 @@ from app.api.registry.registry_utils import decode_auth_header from app.api.registry.registry_service import RegistryService, get_registry_service from app.api.config.logutil import LOGGER +from app.api.user.user_model import UserRole +from app.api.config.auth import JWTDep, get_id_from_jwt, get_username_from_jwt, pre_authorize +from app.api.tags.tag_service import TagService, get_tag_service +from app.api.registry.registry_dto import DeleteTagDTO, DeleteTagResponseDTO +from app.api.repo.repo_service import RepositoryService, get_repo_service +from app.api.access_control.access_control_service import AccessControlService, get_access_control_service +from app.api.config.exception_handler import AccessDeniedException +from app.api.registry.registry_client import RegistryClient, get_registry_client -router = APIRouter(prefix="/registry", tags=["dockerhub-registry"]) +internal_router = APIRouter(prefix="/registry", tags=["dockerhub-registry"]) +external_router = APIRouter(prefix="/registry", tags=["dockerhub-registry-external"]) -@router.get("", summary="???") +@internal_router.get("", summary="???") def registry_endpoint( request: Request, registry_service: RegistryService = Depends(get_registry_service), @@ -25,17 +34,43 @@ def registry_endpoint( return registry_service.handle_registry_request(username, password, scopes, service) -@router.api_route("/notifications", methods=["POST", "PUT"], summary="Webhook for Docker Registry") +@internal_router.api_route("/notifications", methods=["POST", "PUT"], summary="Webhook for Docker Registry") def registry_notification_endpoint(data: dict, registry_service: RegistryService = Depends(get_registry_service)): for event in data["events"]: action = event.get("action", None) username = event.get("actor", {}).get("name", None) repository = event.get("target", {}).get("repository", None) tag = event.get("target", {}).get("tag", None) - + digest = event.get("target", {}).get("digest", None) + method = event.get("request", {}).get("method", None) + url = event.get("target", {}).get("url", None) + try: - registry_service.on_notification(username, action, repository, tag) + registry_service.on_notification(username, action, repository, tag, digest, method, url) except Exception as e: LOGGER.error(f"Couldn't handle Distribution webhook: {e}") - return {} \ No newline at end of file + return {} + +@external_router.delete("/tag", status_code=200, summary="Delete a tag by its name", response_model=DeleteTagResponseDTO) +@pre_authorize([UserRole.user, UserRole.admin]) +async def delete_tag_endpoint( + jwt: JWTDep, + dto: DeleteTagDTO, + repo_service: RepositoryService = Depends(get_repo_service), + registry_service: RegistryService = Depends(get_registry_service), + tag_service: TagService = Depends(get_tag_service), + registry_client: RegistryClient = Depends(get_registry_client), + access_control_service: AccessControlService = Depends(get_access_control_service) +): + user_id = get_id_from_jwt(jwt) + username = get_username_from_jwt(jwt) + repo = repo_service.find_by_id(dto.repo_id) + tag = tag_service.find_by_name_and_repo_id(dto.tag_name, dto.repo_id) + + if not access_control_service.has_delete_tag_access(user_id, repo.id): + raise AccessDeniedException(f"User {user_id} cannot delete a tag {tag.name} of repository with identifier {repo.id}") + + response = await registry_service.delete_tag(registry_client, username, repo, tag) + + return response diff --git a/server/app/api/registry/registry_dto.py b/server/app/api/registry/registry_dto.py index 96fe19c..5fa6b08 100644 --- a/server/app/api/registry/registry_dto.py +++ b/server/app/api/registry/registry_dto.py @@ -5,8 +5,16 @@ class RegistryActionOperation(str, enum.Enum): pull = "pull" push = "push" + delete = "delete" class RegistryAction(BaseModel): username: str repo_canonical_name: str - operations: List[RegistryActionOperation] \ No newline at end of file + operations: List[RegistryActionOperation] + +class DeleteTagDTO(BaseModel): + repo_id: int + tag_name: str + +class DeleteTagResponseDTO(BaseModel): + message: str \ No newline at end of file diff --git a/server/app/api/registry/registry_service.py b/server/app/api/registry/registry_service.py index 386e8ea..5d5f917 100644 --- a/server/app/api/registry/registry_service.py +++ b/server/app/api/registry/registry_service.py @@ -3,11 +3,14 @@ from sqlmodel import Session from app.api.config.database import get_database from app.api.access_control.access_control_service import AccessControlService -from app.api.registry.registry_utils import parse_scopes, build_jwt_for_docker_registry +from app.api.registry.registry_utils import build_manifest_jwt, parse_scopes, build_jwt_for_docker_registry from app.api.user.user_service import UserService from app.api.repo.repo_service import RepositoryService -from app.api.registry.registry_dto import RegistryActionOperation +from app.api.registry.registry_dto import DeleteTagResponseDTO, RegistryActionOperation from app.api.tags.tag_service import TagService +from app.api.repo.repo_model import Repository +from app.api.tags.tag_model import Tag +from app.api.registry.registry_client import RegistryClient class RegistryService: def __init__(self, session: Session): @@ -30,16 +33,55 @@ def __init__(self, session: Session): self.tag_service = TagService(session) self.access_control_service = AccessControlService(session) - def on_notification(self, username: str, action: str, repo_name: str, tag: str | None): - if action == 'push': + def _format_registry_event(self, username: str, action: str, repo_name: str, tag_name: str | None, digest: str, method: str, url: str | None): + + is_layer_action = url is not None and "blobs" in url + is_tag_action = tag_name is not None + is_referrers_action = url is not None and "referrers" in url + + # The condition `(url is None)` is a bit hardcoded — it represents + # a special case when an image manifest link is being deleted. + # This operation always follows the deletion of tag links. + # TODO: Consider revising this logic in the future when implementing repository deletion + is_manifest_action = (url is not None and "manifests" in url) or (url is None) + + if is_layer_action: + target = "layer" + desc = digest + + elif is_tag_action: + target = "tag" + desc = tag_name + + elif is_manifest_action: + target = "manifest" + desc = digest + + elif is_referrers_action: + target = "referrers" + desc = digest + + else: + raise ValueError(f"Unsupported event: action='{action}', repo='{repo_name}', tag='{tag_name}', method='{method}', url='{url}'") + + return f"User '{username}' completed a {action} using method {method} on repo '{repo_name}' ({target}) [{desc}]" + + def on_notification(self, username: str, action: str, repo_name: str, tag_name: str | None, digest: str, method: str, url: str | None): + + if action == 'push' and tag_name is not None: user = self.user_service.find_by_username(username) repo = self.repo_service.find_by_canonical_name(repo_name) - - tag = self.tag_service.on_push(user.id, repo.id, tag) + tag = self.tag_service.on_push(user.id, repo.id, tag_name) print(f"Pushed tag {tag}") - print(f"User '{username}' completed '{action}' of repository '{repo_name}' with tag '{tag}'") + elif action == 'delete' and tag_name is not None: + repo = self.repo_service.find_by_canonical_name(repo_name) + tag = self.tag_service.find_by_name_and_repo_id(tag_name, repo.id) + self.tag_service.remove_tag(tag.id) + print(f"Deleted tag {tag_name}") + message = self._format_registry_event(username, action, repo_name, tag_name, digest, method, url) + print(message) def handle_registry_request(self, username: str, password: str, scopes: List[str] | None, service: str | None): # User with the provided credentials must exist. @@ -89,5 +131,39 @@ def handle_registry_request(self, username: str, password: str, scopes: List[str jwt = build_jwt_for_docker_registry(username, service, scopes) return {"token": jwt} + + async def _fetch_manifest_digest(self, client: RegistryClient, repo_name: str, tag_name: str, username: str) -> str | None: + + jwt = build_manifest_jwt(username, repo_name, "GET") + response = await client.get_manifest(repo_name, tag_name, jwt) + + if response.status_code == 404: + return None + + assert response.status_code == 200 + return response.headers["Docker-Content-Digest"] + + async def _delete_manifest_by_digest(self, client: RegistryClient, repo_name: str, digest: str, username: str) -> None: + jwt = build_manifest_jwt(username, repo_name, "DELETE") + response = await client.delete_manifest(repo_name, digest, jwt) + assert response.status_code == 202 + + async def delete_tag(self, client: RegistryClient, username: str, repo: Repository, tag: Tag) -> DeleteTagResponseDTO: + # If the manifest for a certain tag can't be found, + # it means the manifest has already been deleted. + # Two tags can point to the same manifest. + # If a manifest pointed to by two tags is deleted, + # both tags are also removed from the Distribution. + digest = await self._fetch_manifest_digest(client, repo.canonical_name, tag.name, username) + if digest is None: + self.tag_service.remove_tag(tag.id) + return DeleteTagResponseDTO(message=f"Tag '{tag.name}' successfully deleted from repository '{repo.name}'.") + + # Since deletion of the manifest is accepted (202) + # by Distribution, it is a slightly better approach + # to delete it from the backend database afterward. + await self._delete_manifest_by_digest(client, repo.canonical_name, digest, username) + return DeleteTagResponseDTO(message=f"Tag '{tag.name}' successfully deleted from repository '{repo.name}'.") + def get_registry_service(session: Session = Depends(get_database)) -> RegistryService: return RegistryService(session) \ No newline at end of file diff --git a/server/app/api/registry/registry_utils.py b/server/app/api/registry/registry_utils.py index 5879c1d..8184a87 100644 --- a/server/app/api/registry/registry_utils.py +++ b/server/app/api/registry/registry_utils.py @@ -2,7 +2,6 @@ import datetime import os import uuid - import jwt from typing import List, Literal @@ -11,7 +10,7 @@ SECRET_KEY = "" if os.getenv('mocker_hub_TEST_ENV') is None: try: - with open("/mnt/local/certs/private_key.pem", "r") as f: + with open("/code/certs/private_key.pem", "r") as f: SECRET_KEY = f.read() except FileNotFoundError as e: print(e) @@ -21,7 +20,7 @@ CERT_DER_B64 = "" if os.getenv('mocker_hub_TEST_ENV') is None: try: - with open("/mnt/local/certs/cert.der.b64", "r") as f: + with open("/code/certs/cert.der.b64", "r") as f: CERT_DER_B64 = f.read() except FileNotFoundError as e: print(e) @@ -126,3 +125,14 @@ def build_jwt_for_docker_registry(username: str, service: str, scopes: List[str] ) return jwt.encode(token_payload, SECRET_KEY, algorithm='RS256', headers=token_headers) + +def build_manifest_jwt(username: str, repo_name: str, method: Literal["GET", "DELETE"]) -> str: + + scope = f"repository:{repo_name}:" + scope = scope + "pull" if method == "GET" else scope + "delete" + host = os.environ["DISTRIBUTION_HOST"] + port = os.environ["DISTRIBUTION_PORT"] + service = f"{host}:{port}" + + return build_jwt_for_docker_registry(username, service, [scope]) + diff --git a/server/app/api/repo/repo_service.py b/server/app/api/repo/repo_service.py index 373751a..f22130c 100644 --- a/server/app/api/repo/repo_service.py +++ b/server/app/api/repo/repo_service.py @@ -1,6 +1,6 @@ from typing import List, Tuple from fastapi import Depends -from app.api.config.exception_handler import AccessDeniedException, FieldTakenException, NotFoundException, InvalidInputException +from app.api.config.exception_handler import AccessDeniedException, ConflictException, FieldTakenException, NotFoundException, InvalidInputException from sqlmodel import Session from app.api.config.database import get_database from app.api.user.user_model import User, UserRole @@ -129,6 +129,9 @@ def toggle_repo_star(self, user_id: int, repo_id: int) -> Tuple[Repository, bool repo = self.repo_repo.unstar_repo(repo, user) + if repo is None: + raise ConflictException("The repository star was removed (unstarred) by a different operation") + # `False` indicates the repo is no longer starred return repo, False diff --git a/server/app/api/tags/tag_service.py b/server/app/api/tags/tag_service.py index 0bafc27..8938b1e 100644 --- a/server/app/api/tags/tag_service.py +++ b/server/app/api/tags/tag_service.py @@ -1,6 +1,6 @@ from typing import List from fastapi import Depends -from app.api.config.exception_handler import AccessDeniedException, NotFoundException, UserException +from app.api.config.exception_handler import AccessDeniedException, NotFoundException, NotInRelationshipException, UserException from sqlmodel import Session from app.api.config.database import get_database from app.api.repo.repo_model import Repository @@ -39,20 +39,12 @@ def on_push(self, user_id: int | None, repo_id: int, tag_name: str | None) -> Ta else: return self.tag_repo.update_last_push(existing_tag, user_id) - def remove_tag(self, user_id: int | None, tag_id: int) -> None: - # Fetch the tag. + def remove_tag(self, tag_id: int) -> None: tag = self.tag_repo.find_by_id(tag_id) if tag is None: raise NotFoundException(Tag, tag_id) - # Access control. - - if not self.access_control_service.has_write_access(user_id, tag.repository_id): - raise AccessDeniedException(f"User {user_id} cannot remove tag {tag.name} to repo {tag.repository.canonical_name}") - - # Delete the tag. - self.tag_repo.remove(tag) def get_tags_for_repository(self, repo_id: int) -> List[Tag]: @@ -71,7 +63,12 @@ def filter(self, repo_name: str, search_query: str, params: PaginationParams) -> """ search_query = search_query.strip() return self.tag_repo.filter(repo_name, search_query, params) - + + def find_by_name_and_repo_id(self, name: str, repo_id: int) -> Tag: + tag = self.tag_repo.find_by_name_and_repo_id(name, repo_id) + if tag is None: + raise NotInRelationshipException(Repository, repo_id, Tag, name) + return tag def get_tag_service(session: Session = Depends(get_database)) -> TagService: return TagService(session) \ No newline at end of file diff --git a/server/app/tests/test_registry.py b/server/app/tests/test_registry.py index 9e66ea6..34131a5 100644 --- a/server/app/tests/test_registry.py +++ b/server/app/tests/test_registry.py @@ -1,13 +1,29 @@ +import os +from fastapi.testclient import TestClient import jwt import pytest -from unittest.mock import MagicMock, call, patch +from sqlmodel import SQLModel +from app.api.main import app +from unittest.mock import MagicMock, AsyncMock, call, patch from fastapi import HTTPException from app.api.registry.registry_service import RegistryService from app.api.registry.registry_dto import RegistryActionOperation from app.api.user.user_service import UserService from app.api.repo.repo_service import RepositoryService from app.api.access_control.access_control_service import AccessControlService -from app.api.registry.registry_utils import build_jwt_for_docker_registry, parse_scopes +from app.api.registry.registry_utils import build_jwt_for_docker_registry, parse_scopes, build_manifest_jwt +from app.api.registry.registry_client import RegistryClient +from app.api.config.exception_handler import RegistryException +from app.api.repo.repo_model import Repository +from app.api.tags.tag_model import Tag +from httpx import AsyncClient, Response, Request + +from app.api.config.database import get_database +from app.api.tags.tag_service import TagService +from app.api.team.team_model import TeamMember, TeamPermissionKind, TeamPermission +from app.api.org.org_model import OrganizationMembers + +BUILD_MANIFEST_JWT_PATH = "app.api.registry.registry_service.build_manifest_jwt" @pytest.fixture def registry_service() -> RegistryService: @@ -30,6 +46,12 @@ def mock_jwt_encode(): jwt.encode = original_jwt_encode +@pytest.fixture(scope="function", autouse=True) +def reset_db(): + from app.api.config.database import engine + SQLModel.metadata.drop_all(bind=engine) + SQLModel.metadata.create_all(bind=engine) + def test_handle_registry_request_push(registry_service: RegistryService, mock_jwt_encode): registry_service.user_service.exists_with_credentials.return_value = True registry_service.user_service.find_by_username.return_value = MagicMock(id=1, username="testuser") @@ -174,14 +196,14 @@ def test_handle_registry_request_unknown_operation(registry_service: RegistrySer registry_service.user_service.find_by_username.return_value = MagicMock(id=1, username="testuser") registry_service.repo_service.find_by_canonical_name.return_value = MagicMock(id=1, canonical_name="test/repo") - scopes = ["repository:test/repo:delete"] + scopes = ["repository:test/repo:update"] username = "testuser" password = "password" service = "docker-registry" with pytest.raises(ValueError) as ex: registry_service.handle_registry_request(username, password, scopes, service) - assert "delete" in str(ex) + assert "update" in str(ex) def test_handle_registry_request_jwt_generation(registry_service: RegistryService, mock_jwt_encode): registry_service.user_service.exists_with_credentials.return_value = True @@ -200,7 +222,353 @@ def test_handle_registry_request_jwt_generation(registry_service: RegistryServic registry_service.user_service.exists_with_credentials.assert_called_once_with(username, password) registry_service.access_control_service.has_write_access.assert_called_once_with(1, 1) registry_service.access_control_service.has_read_access.assert_called_once_with(1, 1) + +class TestFetchManifestDigest: + + @pytest.mark.asyncio + @patch(BUILD_MANIFEST_JWT_PATH, return_value="fake-jwt") + async def test_fetch_manifest_digest_404(self, mock_build_jwt, registry_service: RegistryService): + client = AsyncMock(spec=RegistryClient) + response = Response(404) + client.get_manifest = AsyncMock(return_value=response) + + digest = await registry_service._fetch_manifest_digest(client, "repo", "tag", "user") + + assert digest is None + mock_build_jwt.assert_called_once_with("user", "repo", "GET") + client.get_manifest.assert_awaited_once_with("repo", "tag", "fake-jwt") + + @pytest.mark.asyncio + @patch(BUILD_MANIFEST_JWT_PATH, return_value="fake-jwt") + async def test_fetch_manifest_digest_200(self, mock_build_jwt, registry_service: RegistryService): + client = AsyncMock(spec=RegistryClient) + response = Response(200) + client.get_manifest = AsyncMock(return_value=response) + client.get_manifest.return_value.headers = { + "Docker-Content-Digest": "sha256:abc" + } + + digest = await registry_service._fetch_manifest_digest(client, "repo", "tag", "user") + assert digest == "sha256:abc" + client.get_manifest.assert_awaited_once_with("repo", "tag", "fake-jwt") + mock_build_jwt.assert_called_once_with("user", "repo", "GET") + + + @pytest.mark.asyncio + @patch(BUILD_MANIFEST_JWT_PATH, return_value="fake-jwt") + async def test_fetch_manifest_digest_unexpected_status(self, mock_build_jwt, registry_service: RegistryService): + client = AsyncMock(spec=RegistryClient) + err = RegistryException(500, "Server is damaged") + client.get_manifest.side_effect = err + + with pytest.raises(RegistryException) as e: + await registry_service._fetch_manifest_digest(client, "repo", "tag", "user") + + assert e.value.status_code == 500 + assert "Server is damaged" in e.value.message + client.get_manifest.assert_awaited_once_with("repo", "tag", "fake-jwt") + mock_build_jwt.assert_called_once_with("user", "repo", "GET") + +class TestDeleteManifestByDigest: + + @pytest.mark.asyncio + @patch(BUILD_MANIFEST_JWT_PATH, return_value="fake-jwt") + async def test_delete_manifest_by_digest_202(self, mock_build_jwt, registry_service: RegistryService): + client = AsyncMock(spec=RegistryClient) + response = Response(202) + client.delete_manifest = AsyncMock(return_value=response) + + result = await registry_service._delete_manifest_by_digest(client, "repo", "sha256:abc", "user") + + assert result is None + client.delete_manifest.assert_awaited_once_with("repo", "sha256:abc", "fake-jwt") + mock_build_jwt.assert_called_once_with("user", "repo", "DELETE") + + @pytest.mark.asyncio + @patch(BUILD_MANIFEST_JWT_PATH, return_value="fake-jwt") + async def test_delete_manifest_by_digest_assertion_error_on_404(self, mock_build_jwt, registry_service: RegistryService): + client = AsyncMock(spec=RegistryClient) + request = Request("DELETE", "https://registry/v2/my-repo/manifests/sha256:abc") + response = Response(404, request=request) + client.delete_manifest = AsyncMock(return_value=response) + + with pytest.raises(AssertionError): + await registry_service._delete_manifest_by_digest(client, "repo", "sha256:abc", "user") + + client.delete_manifest.assert_awaited_once_with("repo", "sha256:abc", "fake-jwt") + mock_build_jwt.assert_called_once_with("user", "repo", "DELETE") + + @pytest.mark.asyncio + @patch(BUILD_MANIFEST_JWT_PATH, return_value="fake-jwt") + async def test_delete_manifest_by_digest_throws_on_500(self, mock_build_jwt, registry_service: RegistryService): + client = AsyncMock(spec=RegistryClient) + err = RegistryException(500, "Server is damaged") + client.delete_manifest.side_effect = err + + with pytest.raises(RegistryException) as e: + await registry_service._delete_manifest_by_digest(client, "repo", "sha256:abc", "user") + + client.delete_manifest.assert_awaited_once_with("repo", "sha256:abc", "fake-jwt") + assert "Server is damaged" in e.value.message + assert e.value.status_code == 500 + mock_build_jwt.assert_called_once_with("user", "repo", "DELETE") + +class TestDeleteTag: + + @pytest.mark.asyncio + async def test_delete_tag_manifest_not_found(self, registry_service: RegistryService): + repo = Repository(id=1, name="repo", canonical_name="repo") + tag = Tag(id=2, name="latest", repository_id=1) + client = AsyncMock(spec=RegistryClient) + + registry_service._fetch_manifest_digest = AsyncMock(return_value=None) + registry_service.tag_service.remove_tag = MagicMock(return_value=None) + + response = await registry_service.delete_tag(client, "user", repo, tag) + + assert response.message == "Tag 'latest' successfully deleted from repository 'repo'." + registry_service._fetch_manifest_digest.assert_awaited_once_with(client, "repo", "latest", "user") + registry_service.tag_service.remove_tag.assert_called_once_with(2) + + @pytest.mark.asyncio + async def test_delete_tag_manifest_found(self, registry_service: RegistryService): + repo = Repository(id=1, name="repo", canonical_name="repo") + tag = Tag(id=2, name="latest", repository_id=1) + client = AsyncMock(spec=RegistryClient) + + registry_service._fetch_manifest_digest = AsyncMock(return_value="sha256:abc") + registry_service._delete_manifest_by_digest = AsyncMock(return_value=None) + + response = await registry_service.delete_tag(client, "user", repo, tag) + + assert response.message == "Tag 'latest' successfully deleted from repository 'repo'." + registry_service._fetch_manifest_digest.assert_awaited_once_with(client, "repo", "latest", "user") + registry_service._delete_manifest_by_digest.assert_awaited_once_with(client, "repo", "sha256:abc", "user") + + @pytest.mark.asyncio + async def test_delete_tag_throws_on_digest_fetch(self, registry_service: RegistryService): + client = AsyncMock(spec=RegistryClient) + tag = Tag(id=2, name="tag", repository_id=1) + repo = Repository(id=1, name="repo", canonical_name="repo") + + err = RegistryException(500, "Server is damaged") + registry_service._fetch_manifest_digest = AsyncMock(side_effect = err) + + with pytest.raises(RegistryException) as e: + await registry_service.delete_tag(client, "user", repo, tag) + + assert e.value.status_code == 500 + assert "Server is damaged" in e.value.message + registry_service._fetch_manifest_digest.assert_awaited_once_with(client, "repo", "tag", "user") + + @pytest.mark.asyncio + async def test_delete_tag_throws_on_manifest_delete(self, registry_service: RegistryService): + client = AsyncMock(spec=RegistryClient) + tag = Tag(id=2, name="tag", repository_id=1) + repo = Repository(id=1, name="repo", canonical_name="repo") + + err = RegistryException(500, "Server is damaged") + registry_service._delete_manifest_by_digest = AsyncMock(side_effect = err) + registry_service._fetch_manifest_digest = AsyncMock(return_value="sha256:abc") + + with pytest.raises(RegistryException) as e: + await registry_service.delete_tag(client, "user", repo, tag) + + assert e.value.status_code == 500 + assert "Server is damaged" in e.value.message + registry_service._fetch_manifest_digest.assert_awaited_once_with(client, "repo", "tag", "user") + registry_service._delete_manifest_by_digest.assert_awaited_once_with(client, "repo", "sha256:abc", "user") + @patch("app.api.registry.registry_service.build_manifest_jwt", return_value="fake-jwt") + def test_delete_tag__integration(self, mock_build_jwt): + + # NOTE: We can’t add tags through the API, so we need + # to insert them directly into the backend database + # and use the distribution API to preserve the image + # there. This process is very complex. Therefore, this + # test will mock communication with the distribution API. + # An end-to-end test would be the ideal choice to fully + # cover this functionality. + + with TestClient(app) as client: + def add_user(username): + data = { + "username": username, + "email": f"{username}@email.com", + "password": "1234" + } + response = client.post("/api/v1/users/", json=data) + return response.json() + + def log_in(username): + data = {"username": username, "password": "1234"} + response = client.post("/api/v1/users/login", json=data) + return response.json()["token"] + + def add_repo(username, repo_name, org_id = None): + data = { + "name": repo_name, + "desc": "", + "public": True, + "organization_id": org_id, + } + header = {"Authorization": f"Bearer {log_in(username)}"} + + return client.post("/api/v1/repositories/", json=data, headers=header).json() + + def add_org(username, name: str) -> dict: + jwt = log_in(username) + header = {"Authorization": f"Bearer {jwt}"} + + dto = { + "name": name, + "desc": "", + "image": None + } + return client.post("/api/v1/organizations", json=dto, headers=header).json() + + def add_user_to_org(user_id: int, org_id: int) -> OrganizationMembers: + # TODO: Once we implement "add user to org" in the controller, use the proper endpoint for that here. + from app.api.config.database import engine + from app.api.org.org_repo import OrganizationRepo + from app.api.config.database import get_database + + session = next(get_database()) + org_repo = OrganizationRepo(session) + return org_repo.add_user_to_org(org_id, user_id) + + def add_team(username: str, org_id: int, name: str, desc: str = "") -> dict: + jwt = log_in(username) + header = {"Authorization": f"Bearer {jwt}"} + + dto1 = { + "organization_id": org_id, + "name": name, + "desc": desc, + } + return client.post("/api/v1/teams", json=dto1, headers=header).json() + + def add_team_member(user_id: int, team_id: int) -> TeamMember: + # TODO: Once we implement "add team_member" in the controller, use the proper endpoint for that here. + from app.api.team.team_repo import TeamRepo + from app.api.config.database import get_database + + session = next(get_database()) + team_repo = TeamRepo(session) + return team_repo.add_member(team_id, user_id) + + def add_team_permission(team_id: int, repo_id: int, kind: TeamPermissionKind) -> TeamPermission: + # TODO: Once we implement "add_team_permission" in the controller, use the proper endpoint for that here. + from app.api.team.team_repo import TeamRepo + from app.api.config.database import get_database + + session = next(get_database()) + team_repo = TeamRepo(session) + return team_repo.add_permission(team_id, repo_id, kind).model_dump() + + def add_tag(user_id, repo_id, tag_name): + session = next(get_database()) + tag_service = TagService(session) + return tag_service.on_push(user_id, repo_id, tag_name).model_dump() + + def search_tags(repo_canonical_name): + return client.get(f"/api/v1/tags/?repo_name={repo_canonical_name}").json() + + def delete_tag(repo_id, tag_name, username): + data = { + "repo_id": repo_id, + "tag_name": tag_name + } + header = {"Authorization": f"Bearer {log_in(username)}"} + return client.request("DELETE", "/api/v1/registry/tag", json=data, headers=header) + + class MockResponse: + def __init__(self, status_code, headers = {}): + self.status_code = status_code + self.headers = headers + + async def mock_get_manifest(self, repo_name, tag_name, token): + return MockResponse(200, {"Docker-Content-Digest": "sha256:abc"}) + + async def mock_get_manifest_502(self, repo_name, tag_name, token): + raise RegistryException(502, "Error when calling Distribution") + + async def mock_get_missing_manifest(self, repo_name, tag_name, token): + return MockResponse(404) + + async def mock_delete_manifest(self, repo_name, digest, token): + return MockResponse(202) + + def patch_object(type): + if type == "get_manifest": + return patch.object(RegistryClient, "get_manifest", new=mock_get_manifest) + elif type == "delete_manifest": + return patch.object(RegistryClient, "delete_manifest", new=mock_delete_manifest) + elif type == "get_manifest_unresponsive": + return patch.object(RegistryClient, "get_manifest", new=mock_get_manifest_502) + elif type == "get_missing_manifest": + return patch.object(RegistryClient, "get_manifest", new=mock_get_missing_manifest) + + u1 = add_user("u1") + u2 = add_user("u2") + o1 = add_org("u1", "o1") + r1 = add_repo("u1", "r1") + r2 = add_repo("u1", "r2", o1["id"]) + r3 = add_repo("u1", "r3", o1["id"]) + t1 = add_tag(u1["id"], r1["id"], "t1") + t2 = add_tag(u1["id"], r1["id"], "t2") + t3 = add_tag(u1["id"], r2["id"], "t3") + t4 = add_tag(u1["id"], r3["id"], "t4") + + add_user_to_org(u2["id"], o1["id"]) + tm1 = add_team("u1", o1["id"], "tm1") + add_team_member(u2["id"], tm1["id"]) + add_team_permission(tm1["id"], r2["id"], TeamPermissionKind.admin) + add_team_permission(tm1["id"], r3["id"], TeamPermissionKind.read_write) + + # 1) Repo doesn't exist. + with patch_object("get_manifest"), patch_object("delete_manifest"): + response = delete_tag(99, t1["name"], u1["username"]) + assert response.status_code == 404 + + # 2) User is not authorized to delete tag. + with patch_object("get_manifest"), patch_object("delete_manifest"): + response = delete_tag(r1["id"], t2["name"], u2["username"]) + assert response.status_code == 400 + + # 3) Tag and manifest exist, but the tag is not deleted from the backend DB + # because deletion is postponed until a notification is received Distribution. + def standard_deleting_test(repo, tag, user): + with patch_object("get_manifest"), patch_object("delete_manifest"): + response = delete_tag(repo["id"], tag["name"], user["username"]) + assert response.status_code == 200 + standard_deleting_test(r1, t1, u1) + + # 4) Tag exists, but the manifest doesn't; two tags were pointing to the same image. + with patch_object("get_missing_manifest"): + response = delete_tag(r1["id"], t2["name"], u1["username"]) + assert response.status_code == 200 + tags = search_tags(r1['canonical_name']) + assert len(tags) == 1 + + # 5) Tag doesn't exist. + with patch_object("get_manifest"), patch_object("delete_manifest"): + response = delete_tag(r1["id"], t2["name"], u1["username"]) + assert response.status_code == 400 + + # 6) Distribution is unresponsive. + with patch_object("get_manifest_unresponsive"): + response = delete_tag(r1["id"], t1["name"], u1["username"]) + assert response.status_code == 502 + payload = response.json() + assert "Error when calling Distribution" in payload["detail"]["message"] + + # 7) Delete tag as an organization member with `admin` permissions. + standard_deleting_test(r2, t3, u2) + + # 8) Delete tag as an organization member with `read_write` permissions. + standard_deleting_test(r3, t4, u2) + # ----------------------------------- # Util functions # ----------------------------------- @@ -215,19 +583,18 @@ def test_parse_scopes_valid_input(): assert action.username == "testuser" assert action.repo_canonical_name == "test/repo1" assert action.operations == [RegistryActionOperation.push, RegistryActionOperation.pull] - action = actions[1] assert action.username == "testuser" assert action.repo_canonical_name == "test/repo2" assert action.operations == [RegistryActionOperation.pull] def test_parse_scopes_invalid_scope(): - scopes = ["repository:test/repo:push,delete"] + scopes = ["repository:test/repo:push,update"] username = "testuser" with pytest.raises(ValueError) as ex: parse_scopes(username, scopes) - assert "delete" in str(ex) + assert "update" in str(ex) def test_build_jwt_for_docker_registry_with_scope(mock_jwt_encode): username = "testuser" @@ -248,3 +615,213 @@ def test_build_jwt_for_docker_registry_without_scope(mock_jwt_encode): assert isinstance(jwt_token, str) assert len(jwt_token) > 0 + +@pytest.mark.parametrize("method, expected_scope", [ + ("GET", "repository:repo:pull"), + ("DELETE", "repository:repo:delete"), +]) +@patch("app.api.registry.registry_utils.build_jwt_for_docker_registry", return_value="fake-jwt-token") +def test_build_manifest_jwt_calls_build_jwt_correctly(mock_build_jwt, method, expected_scope): + + os.environ["DISTRIBUTION_HOST"] = "host" + os.environ["DISTRIBUTION_PORT"] = "port" + + service = f"{os.environ['DISTRIBUTION_HOST']}:{os.environ['DISTRIBUTION_PORT']}" + username = "user" + + token = build_manifest_jwt(username, "repo", method) + + assert token == "fake-jwt-token" + mock_build_jwt.assert_called_once_with(username, service, [expected_scope]) + +class TestFormatRegistryEvent: + + def test_layer_action(self, registry_service: RegistryService): + msg = registry_service._format_registry_event( + username="user", + action="push", + repo_name="repo", + tag_name=None, + digest="sha:123", + method="PUT", + url="/v2/repo/blobs/sha:123" + ) + assert "user" in msg + assert "push" in msg + assert "repo" in msg + assert "PUT" in msg + + assert "sha:123" in msg + assert "layer" in msg + + def test_tag_action(self, registry_service: RegistryService): + msg = registry_service._format_registry_event( + username="user", + action="delete", + repo_name="repo", + tag_name="v1", + digest="sha:123", + method="DELETE", + url="/v2/repo/manifests/sha:123" + ) + assert "user" in msg + assert "delete" in msg + assert "DELETE" in msg + assert "repo" in msg + + assert "v1" in msg + assert "tag" in msg + + def test_manifest_action(self, registry_service: RegistryService): + msg = registry_service._format_registry_event( + username="user", + action="pull", + repo_name="repo", + tag_name=None, + digest="sha:123", + method="GET", + url="/v2/repo/manifests/sha:123" + ) + assert "user" in msg + assert "pull" in msg + assert "repo" in msg + assert "GET" in msg + + assert "manifest" in msg + assert "sha:123" in msg + + def test_referrers_action(self, registry_service: RegistryService): + msg = registry_service._format_registry_event( + username="user", + action="pull", + repo_name="repo", + tag_name=None, + digest="sha:123", + method="GET", + url="/v2/repo/referrers/sha:123" + ) + assert "user" in msg + assert "pull" in msg + assert "repo" in msg + assert "GET" in msg + + assert "referrers" in msg + assert "sha:123" in msg + + def test_unsupported_event_raises(self, registry_service: RegistryService): + with pytest.raises(ValueError): + registry_service._format_registry_event( + username="user", + action="unknown", + repo_name="repo", + tag_name=None, + digest="sha:123", + method="POST", + url="/v2/repo/..." + ) + +# ----------------------------------- +# Client functions +# ----------------------------------- + +class TestRegistryClient: + + @pytest.mark.parametrize("method", ["GET", "DELETE"]) + class TestRequest(): + + @pytest.mark.asyncio + async def test_request_returns_valid_response(monkeypatch, method): + + token = "fake-token" + url = "http://host:9999/..." + response = Response(200, request=Request(method, url)) + async_client = AsyncMock(spec=AsyncClient) + async_client.request.return_value = response + + client = RegistryClient(async_client, "host", "9999") + + result = await client._request(method, url, token) + + assert result.status_code == 200 + async_client.request.assert_awaited_once_with(method, url, headers={"Authorization": f"Bearer {token}"}) + + @pytest.mark.asyncio + async def test_request_raises_on_http_error(monkeypatch, method): + + token = "fake-token" + url = "http://host:9999/..." + async_client = AsyncMock(spec=AsyncClient) + async_client.request.side_effect = Exception("HTTP calling error") + + client = RegistryClient(async_client, "host", "9999") + + with pytest.raises(RegistryException) as e: + await client._request(method, url, token) + + assert e.value.status_code == 502 + async_client.request.assert_awaited_once_with(method, url, headers={"Authorization": f"Bearer {token}"}) + + @pytest.mark.asyncio + async def test_request_raises_on_unexpected_status(monkeypatch, method): + + token = "fake-token" + url = "http://host:9999/..." + request = Request(method, url) + response = Response(500, request=request, content="Server is damaged") + async_client = AsyncMock(spec=AsyncClient) + async_client.request.return_value = response + + client = RegistryClient(async_client, "host", "port") + + with pytest.raises(RegistryException) as e: + await client._request(method, url, token) + + assert e.value.status_code == 500 + assert "Server is damaged" in e.value.message + async_client.request.assert_awaited_once_with(method, url, headers={"Authorization": f"Bearer {token}"}) + + # Note: `TestGetManifest` and `TestDeleteManifest` implicitly provide + # sufficient coverage for `_create_manifest_path` and `_get_scheme`. + + class TestGetManiest: + + @pytest.mark.asyncio + async def test_get_manifest_calls_request_with_correct_args(self): + + token = "fake-token" + expected_url = "https://host:9999/v2/my_repo/manifests/my_tag" + expected_headers = { + "Accept": "application/vnd.docker.distribution.manifest.v2+json, " + "application/vnd.oci.image.index.v1+json, " + "application/vnd.oci.image.manifest.v1+json" + } + + response = Response(200, request=Request("GET", expected_url)) + async_client = AsyncMock(spec=AsyncClient) + client = RegistryClient(client=async_client, host="host", port="9999", secured=True) + + client._request = AsyncMock(return_value=response) + + response = await client.get_manifest("my_repo", "my_tag", token) + + client._request.assert_awaited_once_with("GET", expected_url, token, headers=expected_headers) + assert response.status_code == 200 + + class TestDeleteManifest: + + @pytest.mark.asyncio + async def test_delete_manifest_calls_request_with_correct_args(self): + token = "fake-token" + expected_url = "https://host:9999/v2/my_repo/manifests/sha256:abc" + + response = Response(202, request=Request("DELETE", expected_url)) + async_client = AsyncMock(spec=AsyncClient) + client = RegistryClient(client=async_client, host="host", port="9999", secured=True) + + client._request = AsyncMock(return_value=response) + + response = await client.delete_manifest("my_repo", "sha256:abc", token) + + client._request.assert_awaited_once_with("DELETE", expected_url, token) + assert response.status_code == 202 + \ No newline at end of file diff --git a/server/app/tests/test_repository.py b/server/app/tests/test_repository.py index ed0e92b..2cfae8f 100644 --- a/server/app/tests/test_repository.py +++ b/server/app/tests/test_repository.py @@ -10,7 +10,7 @@ from app.api.user.user_model import User, UserRole from app.api.user.user_repo import UserRepo from app.api.user.user_service import UserService -from app.api.config.exception_handler import InvalidInputException, FieldTakenException, NotFoundException +from app.api.config.exception_handler import ConflictException, InvalidInputException, FieldTakenException, NotFoundException from app.api.repo.repo_repo import RepositoryRepo from app.api.repo.repo_service import RepositoryService from app.api.repo.repo_dto import RepositoryCreateDTO, RepositoryDescUpdateDTO, RepositoryVisibilityUpdateDTO @@ -843,7 +843,7 @@ def test_when_repo_star_suddenly_missing(self, repo_service): repo_service.repo_repo.find_by_id.return_value = start_repo repo_service.repo_repo.unstar_repo.return_value = None - with pytest.raises(NotFoundException): + with pytest.raises(ConflictException): repo_service.toggle_repo_star(user_id, repo_id) def test_toggle_repo_star___integration(): diff --git a/server/app/tests/test_tag.py b/server/app/tests/test_tag.py index b6ec7d5..a6ba03c 100644 --- a/server/app/tests/test_tag.py +++ b/server/app/tests/test_tag.py @@ -52,43 +52,20 @@ def test_on_push_success(self, tag_service: "TagService"): class TestRemoveTag: def test_remove_tag_success(self, tag_service: "TagService"): - user_id = 1 tag_id = 1 - tag = Tag(id=tag_id, name="v1.0", repository_id=1) - repo = Repository(id=1, canonical_name="repo1") tag_service.tag_repo.find_by_id.return_value = tag - tag_service.repo_repo.find_by_id.return_value = repo - tag_service.access_control_service.has_write_access.return_value = True tag_service.tag_repo.remove = MagicMock() - - tag_service.remove_tag(user_id, tag_id) - - tag_service.tag_repo.remove.assert_called_once_with(tag) - - def test_remove_tag_access_denied(self, tag_service: "TagService"): - user_id = 2 - tag_id = 1 - repo = Repository(id=1, canonical_name="repo1") - tag = Tag(id=tag_id, name="v1.0", repository_id=1, repository=repo) - - tag_service.tag_repo.find_by_id.return_value = tag - tag_service.repo_repo.find_by_id.return_value = repo - tag_service.access_control_service.has_write_access.return_value = False - - with pytest.raises(AccessDeniedException): - tag_service.remove_tag(user_id, tag_id) + tag_service.remove_tag(tag_id) + tag_service.tag_repo.remove.assert_called_once_with(tag) def test_remove_tag_not_found(self, tag_service: "TagService"): - user_id = 1 tag_id = 999 - tag_service.tag_repo.find_by_id.return_value = None - with pytest.raises(NotFoundException): - tag_service.remove_tag(user_id, tag_id) + tag_service.remove_tag(tag_id) class TestSearchTags: def test_search_tags_success(self, tag_service: "TagService"): diff --git a/server/entrypoint.sh b/server/entrypoint.sh index d09bc89..bb665c6 100644 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -2,6 +2,19 @@ echo "RUN_TESTS: $RUN_TESTS" # Debugging line +if [ ! -f "/code/certs/cert.pem" ]; then + echo "cert.pem not found..." + + if [ -f "/code/certs/certs.zip" ]; then + echo "certs.zip found. Extracting certificates..." + 7z e /code/certs/certs.zip -o/code/certs/ -y + echo "Certificates extracted." + else + echo "Neither cert.pem nor certs.zip found!" + exit 1 + fi +fi + if [ "$RUN_TESTS" = "true" ]; then echo "Running tests..." ls -l diff --git a/server/requirements.txt b/server/requirements.txt index ab34b4e..da6aa35 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -11,6 +11,7 @@ cryptography fastapi-cache2[redis] pytest +pytest-asyncio httpx pillow