Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
568b901
feat: Update DTO
Vasilijez Jun 4, 2025
1965625
feat: Create initial `delete_tag_endpoint`
Vasilijez Jun 4, 2025
ef1e0ee
fix: Add missing `NotInRelationshipException` import
Vasilijez Jun 4, 2025
b104950
feat: Unify `pre_authorize` async/sync handling
Vasilijez Jun 4, 2025
cbcdfb7
feat: Implement `has_delete_tag_access`
Vasilijez Jun 4, 2025
c9af4c8
refactor: Promote least privilege by splitting token builders
Vasilijez Jun 5, 2025
0388ca0
feat: Cert update for backend HTTP to registry
Vasilijez Jun 8, 2025
6fa1ad2
docs: Improve readability
Vasilijez Jun 9, 2025
c098241
refactor: Create `RegistryClient`
Vasilijez Jun 9, 2025
0fe69bc
refactor: Move logic to service & handle exceptions
Vasilijez Jun 9, 2025
2b447a5
fix: Add missing `remove_tag` method
Vasilijez Jun 9, 2025
4ef55d2
fix: Activate `RegistryClient`
Vasilijez Jun 9, 2025
cf54ac3
refactor: Eliminate redundancy by creating `build_manifest_jwt`
Vasilijez Jun 9, 2025
ced6b6b
fix: Cover all types of exceptions when calling Distribution
Vasilijez Jun 11, 2025
e1378b9
test: Retune the old tests
Vasilijez Jun 11, 2025
aba849c
test: Add all unit tests
Vasilijez Jun 11, 2025
8e5501e
chore: Remove unused code
Vasilijez Jun 11, 2025
480012b
test: Write integration test
Vasilijez Jun 11, 2025
9b4abb6
fix: Address flaky legacy unit test
Vasilijez Jun 12, 2025
e1b6c71
chore: Separate internal and external routers in `registry_controller`
Vasilijez Jun 12, 2025
e6f7563
feat: Add `Delete tag` button
Vasilijez Jun 13, 2025
1a197e9
feat: Add `.sh` script to run Distribution garbage collector
Vasilijez Jun 13, 2025
7ebd54a
fix: Use `canonical_name` instead of `name`
Vasilijez Jun 14, 2025
aeb09bc
fix: Print `tag_name` instead of `None`
Vasilijez Jun 14, 2025
7bd7cbc
fix: Remove `Base image` label
Vasilijez Jun 17, 2025
8c41910
feat: Write `_format_registry_event`
Vasilijez Jun 17, 2025
72e69fc
test: Write unit tests for `_format_registry_event`
Vasilijez Jun 17, 2025
e7fdb12
docs: Add optional `[:{tag}]` command
Vasilijez Jun 17, 2025
5f295e7
Merge branch 'develop' into feature-delete-tag
Vasilijez Jun 18, 2025
8815fb3
fix: Correct bad conflict resolution
Vasilijez Jun 18, 2025
cfd920d
feat: Handle image manifest link deletion
Vasilijez Jun 18, 2025
490e199
feat: Unzip certificates by default
Vasilijez Jun 18, 2025
d98c810
chore: Remove unnecessary comments
Vasilijez Jun 19, 2025
6d85e5a
feat: Wrap spinner around delete tag button
Vasilijez Jun 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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}]
```
Comment thread
magley marked this conversation as resolved.

Log out:
Expand Down
16 changes: 15 additions & 1 deletion client/src/api/tags.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AxiosResponse<TagDTO[]>> {
return await axiosInstance.get(`/tags/?repo_name=${repo_canonical_name}`);
Expand All @@ -21,4 +30,9 @@ export class TagsService {

return await axiosInstance.get(`/tags/filter?${queryParams.toString()}`);
}


static async DeleteTag(dto: DeleteTagDTO): Promise<AxiosResponse<DeleteTagResponseDTO>> {
return await axiosInstance.delete(`/registry/tag`, { data: dto });
}
}
73 changes: 52 additions & 21 deletions client/src/components/RepoTags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -20,6 +22,8 @@ export const RepoTags: React.FC<{ isActive: boolean, repo: RepoExtDTO }> = (prop
const [itemsPerPage, setItemsPerPage] = useState<number>(10);
const [currentPage, setCurrentPage] = useState<number>(1);
const [totalPages, setTotalPages] = useState<number>(1);
const addToast = useToastStore((state) => state.addToast);
const [deletingTag, setDeletingTag] = useState<string | null>(null);

useEffect(() => {
if (props.isActive) {
Expand Down Expand Up @@ -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 (
<div className="tab-pane fade show active m-5" id="tags">
{/* Error? */}
Expand Down Expand Up @@ -104,29 +126,38 @@ export const RepoTags: React.FC<{ isActive: boolean, repo: RepoExtDTO }> = (prop
tags.map((tag) => (
<Col key={tag.id} xs={12}>
<Card className="m-3">
<Card.Body>
{/* Tag title */}
<Card.Title style={{ fontSize: '0.8rem' }}>Tag</Card.Title>
<Card.Body className="d-flex justify-content-between align-items-center">
<div>
{/* Tag title */}
<Card.Title style={{ fontSize: '0.8rem' }}>Tag</Card.Title>

{/* Tag name */}
{tag.name ?
{/* Tag name */}
<Card.Subtitle style={{ fontSize: '1.2rem' }}>{tag.name}</Card.Subtitle>
:
<i>(Base image generated by Distribution)</i>
}

{/* Last pushed */}
<Card.Text className="mt-1 mb-2 text-muted" style={{ fontSize: '0.8rem' }}>
Last pushed <OverlayTrigger
placement="top"
overlay={<Tooltip>{new Date(tag.last_push).toLocaleString()}</Tooltip>}>
<b>{formatDistanceToNow(new Date(tag.last_push), { addSuffix: true })}</b>
</OverlayTrigger> by <NavLink to={`/u/${tag.last_pushed_by_username}/repos`}>{tag.last_pushed_by_username}</NavLink>
</Card.Text>

{/* ... */}
<Card.Text style={{ fontSize: '0.8rem' }}>
</Card.Text>
{/* Last pushed */}
<Card.Text className="mt-1 mb-2 text-muted" style={{ fontSize: '0.8rem' }}>
Last pushed <OverlayTrigger
placement="top"
overlay={<Tooltip>{new Date(tag.last_push).toLocaleString()}</Tooltip>}>
<b>{formatDistanceToNow(new Date(tag.last_push), { addSuffix: true })}</b>
</OverlayTrigger> by <NavLink to={`/u/${tag.last_pushed_by_username}/repos`}>{tag.last_pushed_by_username}</NavLink>
</Card.Text>

{/* ... */}
<Card.Text style={{ fontSize: '0.8rem' }}>
</Card.Text>
</div>

{/* Delete tag */}
{/* We can reuse `can_update` as `can_delete_tag` since the access-control logic is identical. */}
{props.repo?.can_update && tag.name &&
<Button variant="danger" className="me-2" onClick={() => handleDeleteTag(tag)} title="Delete tag" disabled={deletingTag === tag.name}>
{deletingTag === tag.name
? (<output><span className="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>Deleting...</output>)
: <><i className="bi bi-trash" />Delete</>
}
</Button>
}
</Card.Body>
</Card>
</Col>
Expand Down
9 changes: 8 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Comment thread
magley marked this conversation as resolved.

elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.6.2
Expand Down
4 changes: 3 additions & 1 deletion distribution/certs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file removed distribution/certs/certs.rar
Binary file not shown.
Binary file added distribution/certs/certs.zip
Binary file not shown.
4 changes: 3 additions & 1 deletion distribution/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ log:
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
http:
addr: 0.0.0.0:5000
tls:
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions distribution/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions server/app/api/access_control/access_control_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
9 changes: 6 additions & 3 deletions server/app/api/config/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Comment thread
magley marked this conversation as resolved.
return wrapper
return decorator
25 changes: 24 additions & 1 deletion server/app/api/config/exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()})
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)})
8 changes: 8 additions & 0 deletions server/app/api/config/initialize.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading