Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: lint
on:
push
jobs:

lint:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '>=1.22'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason we're pinning the python version and not the go version?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I must have copy-pasted this config without thinking. In theory it should be fine since go cares about backward compatibility, but pinning makes more sense.

- name: Install dependencies
run: make setup-lint
- name: Run lint
run: . venv/bin/activate && make lint
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.11'
- name: Set up Go
uses: actions/setup-go@v5
with:
Expand All @@ -31,7 +31,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.10'
python-version: '3.11'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2

Expand Down
30 changes: 21 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,31 @@ setup-test-e2e:
( cd racetrack_commons && make setup )
@echo Activate your venv: . venv/bin/activate

setup-lint:
uv venv venv &&\
. venv/bin/activate &&\
uv pip install -r requirements-test.txt -r requirements-dev.txt \
-r racetrack_client/requirements.txt -e racetrack_client@racetrack_client \
-r racetrack_commons/requirements.txt -e racetrack_commons@racetrack_commons \
-r lifecycle/requirements.txt -e lifecycle@lifecycle \
-r image_builder/requirements.txt -e image_builder@image_builder \
-r dashboard/requirements.txt -e dashboard@dashboard
@echo Activate your venv: . venv/bin/activate

install-racetrack-client:
( cd racetrack_client && pip install -e . )

lint:
-python -m mypy --ignore-missing-imports --exclude 'racetrack_client/build' racetrack_client
-python -m mypy --ignore-missing-imports racetrack_commons
-python -m mypy --ignore-missing-imports --exclude 'lifecycle/lifecycle/django/registry/migrations' lifecycle
-python -m mypy --ignore-missing-imports image_builder
-python -m mypy --ignore-missing-imports dashboard
-python -m flake8 --ignore E501 --per-file-ignores="__init__.py:F401" \
lifecycle image_builder dashboard
-python -m pylint --disable=R,C,W \
lifecycle/lifecycle image_builder/image_builder dashboard/dashboard
python -m mypy --ignore-missing-imports --exclude 'racetrack_client/build' racetrack_client; e1=$$?;\
python -m mypy --ignore-missing-imports racetrack_commons; e2=$$?;\
python -m mypy --ignore-missing-imports --exclude 'lifecycle/lifecycle/django/registry/migrations' lifecycle; e3=$$?;\
python -m mypy --ignore-missing-imports image_builder; e4=$$?;\
python -m mypy --ignore-missing-imports dashboard; e5=$$?;\
python -m flake8 --ignore E501 --per-file-ignores="__init__.py:F401 lifecycle/lifecycle/event_stream/server.py:E402" \
lifecycle image_builder dashboard; e6=$$?;\
python -m pylint --disable=R,C,W \
lifecycle/lifecycle image_builder/image_builder dashboard/dashboard; e7=$$?;\
exit "$$(( e1 || e2 || e3 || e4 || e5 || e6 || e7 ))"
Comment on lines +92 to +101
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not enough of a Makefile guru to understand what is going on here. It seems like we previously ignored non-zero exit codes, but now we exit with the cumulative or of every exit code? Why, and what does it even mean to or an exit code - is it just to check if any are truthy (I.E, nonzero)?

Also, does this even work? I was under the impression that Makefiles exited upon a nonzero return code. With the initial dash deleted, that's the case here, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This basically takes all exit codes and "or" on them will return 0 if all of them were 0, some other code if at least one was not 0. It works, but it's ugly - this is purely bash syntax used, since it's makefile maybe I could split it into multiple commands and last one dependent on them? I would need to experiment a bit.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it works then I'm not too fussed either way. Makefiles are not exactly known for their graceful syntax


format:
python -m black -S --diff --color -l 120 \
Expand Down
6 changes: 3 additions & 3 deletions dashboard/dashboard/api/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os

import httpx
from fastapi import FastAPI, Request, Response, Body
from fastapi import FastAPI, Request, Body
from starlette.background import BackgroundTask
from starlette.responses import StreamingResponse
from starlette.datastructures import MutableHeaders
Expand Down Expand Up @@ -30,7 +30,7 @@ def _status():
'grafana_url': get_external_grafana_url(),
'site_name': os.environ.get('SITE_NAME', ''),
}

setup_docs_endpoints(app)
setup_proxy_endpoints(app)

Expand All @@ -40,7 +40,7 @@ def setup_proxy_endpoints(app: FastAPI):
lifecycle_api_url = trim_url(os.environ.get('LIFECYCLE_URL', 'http://127.0.0.1:7202'))
logger.info(f'Forwarding API requests to "{lifecycle_api_url}"')
client = httpx.AsyncClient(base_url=f"{lifecycle_api_url}/")

async def _proxy_api_call(request: Request, path: str, payload=Body(default={})):
"""Forward API call to Lifecycle service"""
subpath = f'/api/v1/{request.path_params["path"]}'
Expand Down
4 changes: 2 additions & 2 deletions dashboard/dashboard/api/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def _get_docs_index() -> dict:
'doc_pages': sorted(doc_pages, key=lambda x: x['title'].lower()),
'plugin_pages': sorted(plugin_pages, key=lambda x: x['title'].lower()),
}

@app.get('/api/docs/page/{doc_path:path}')
def _get_docs_page(doc_path: str) -> dict:
docs_path = _get_docs_root_dir()
Expand All @@ -51,7 +51,7 @@ def _get_docs_page(doc_path: str) -> dict:
'doc_name': doc_path,
'html_content': html,
}

@app.get('/api/docs/plugin/{plugin_name}')
def _get_docs_plugin_page(plugin_name: str) -> dict:
plugin_client = LifecyclePluginClient()
Expand Down
6 changes: 3 additions & 3 deletions dashboard/dashboard/api/webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ def setup_web_views(app: FastAPI):
@app.get("/", tags=['ui'])
def _ui_root_view():
return RedirectResponse('/dashboard/ui/')

@app.get("/ui", tags=['ui'])
def _ui_home_view():
return FileResponse('static/index.html')

@app.get("/ui/", tags=['ui'])
def _ui_home_slash_view():
return FileResponse('static/index.html')
Expand All @@ -26,7 +26,7 @@ def _ui_home_slash_view():
app.mount("/ui/assets", StaticFiles(directory="static/assets/"), name="static_front")
else:
logger.warning('No static/assets directory found')

@app.get("/ui/{subpath:path}", tags=['ui'])
def _ui_home_any_view(subpath: str):
return FileResponse('static/index.html')
1 change: 0 additions & 1 deletion image_builder/image_builder/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from image_builder.health import health_response
from image_builder.scheduler import schedule_tasks_async
from racetrack_client.client_config.io import load_credentials_from_dict
from racetrack_client.log.exception import log_exception
from racetrack_client.log.logs import configure_logs
from racetrack_client.manifest.load import load_manifest_from_dict
from racetrack_client.utils.config import load_config
Expand Down
1 change: 0 additions & 1 deletion image_builder/image_builder/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from image_builder.progress import update_deployment_phase
from image_builder.verify import verify_manifest_consistency
from image_builder.warnings import update_deployment_warnings
from racetrack_client.client.env import merge_env_vars
from racetrack_client.client_config.client_config import Credentials
from racetrack_client.log.context_error import wrap_context
from racetrack_client.log.logs import get_logger
Expand Down
6 changes: 3 additions & 3 deletions image_builder/image_builder/docker/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def build(
built_images: list[str] = []
images_num = job_type.images_num
for image_index in range(images_num):
progress = f'({image_index+1}/{images_num})'
progress = f'({image_index + 1}/{images_num})'

try:
_build_job_image(
Expand Down Expand Up @@ -97,7 +97,7 @@ def _build_job_image(
built_images: list[str],
build_flags: list[str],
):
progress = f'({image_index+1}/{images_num})'
progress = f'({image_index + 1}/{images_num})'
image_def: JobTypeImageDef = job_type.images[image_index]
with phase_context(f'building image {progress}', metric_labels, deployment_id, config, metric_phase='building image'):

Expand Down Expand Up @@ -161,7 +161,7 @@ def _build_container_image(
'DOCKER_BUILDKIT=1 docker buildx build',
f'-t {image_name}',
f'-f {dockerfile_path}',
f'--network=host',
'--network=host',
f'--build-context {JOBTYPE_BUILD_CONTEXT}="{jobtype_dir}"',
]
cmd_parts.extend(build_flags)
Expand Down
7 changes: 5 additions & 2 deletions image_builder/image_builder/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ def verify_manifest_consistency(submitted_yaml: str, workspace: Path, repo_dir:
logger.info(f'Manifest file in Job repository:\n{repo_dict}')
difference = _differentiate_dicts(repo_dict, submitted_dict)
warning = ('Submitted job manifest is not consistent with the file found in a repository. '
'Did you forget to do "git push"? '
f'Difference: {difference}')
'Did you forget to do "git push"? '
f'Difference: {difference}')
logger.warning(warning)
return warning

return None


def _find_workspace_manifest_file(workspace: Path, repo_dir: Path) -> Optional[Path]:
paths_to_check = [
workspace / JOB_MANIFEST_FILENAME,
Expand Down
26 changes: 16 additions & 10 deletions lifecycle/lifecycle/auth/authenticate_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
UNUSABLE_PASSWORD_SUFFIX_LENGTH = 40
UNUSABLE_PASSWORD_PREFIX = "!"


def authenticate(username: str, password: str) -> Optional[User]:
"""
If the given credentials are valid, return a User object.
Expand All @@ -18,17 +19,22 @@ def authenticate(username: str, password: str) -> Optional[User]:

return user


def authenticate_user(username: str, password: str) -> Optional[User]:
try:
user: User = LifecycleCache.record_mapper().find_one(User, username=username)
except EntityNotFound:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user

make_password(password)
else:
if check_password(password, user.password) and user.is_active:
return user
try:
user: User = LifecycleCache.record_mapper().find_one(User, username=username)
except EntityNotFound:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user

make_password(password)

return None

if check_password(password, user.password) and user.is_active:
return user

return None


class PermissionDenied(Exception):
Expand Down
2 changes: 1 addition & 1 deletion lifecycle/lifecycle/auth/authorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ def list_permitted_jobs(
family_to_job_ids[job.name].append(job.id)

job_ids = set()
for permission in permissions:
for permission in permissions:
if permission.job_family_id is None and permission.job_id is None:
return all_jobs

Expand Down
30 changes: 21 additions & 9 deletions lifecycle/lifecycle/auth/hasher.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
from __future__ import annotations

import base64
import functools
import hashlib
import math
from typing import Optional
from typing import Optional, Protocol
import secrets

from typing import TYPE_CHECKING
if TYPE_CHECKING:
from _typeshed import ReadableBuffer


RANDOM_STRING_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"


Expand All @@ -14,6 +21,11 @@ def make_password(password: str) -> str:
return hasher.encode(password, salt)


class Hash(Protocol):
def __call__(self, string: ReadableBuffer = b"", *, usedforsecurity: bool = True) -> hashlib._Hash:
...
Comment on lines +24 to +26
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this do except be a placeholder? If it really is only a placeholder, why?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, this one is weird. There is no type I could import from hash library that would allow me to correctly type digest few lines below, so I had to write correct interface myself.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh. Put a comment in about it, otherwise this just looks wrong.



class PBKDF2PasswordHasher:
"""
Secure password hashing using the PBKDF2 algorithm (recommended)
Expand All @@ -25,10 +37,10 @@ class PBKDF2PasswordHasher:

algorithm: str = "pbkdf2_sha256"
iterations: int = 600000
digest: 'hashlib._Hash' = hashlib.sha256
digest: Hash = hashlib.sha256
salt_entropy: int = 128

def encode(self, password: str, salt, iterations: Optional[int]=None):
def encode(self, password: str, salt, iterations: Optional[int] = None):
iterations = iterations or self.iterations
hash = pbkdf2(password, salt, iterations, digest=self.digest)
hash = base64.b64encode(hash).decode("ascii").strip()
Expand Down Expand Up @@ -60,7 +72,7 @@ def salt(self) -> str:
return get_random_string(char_count, allowed_chars=RANDOM_STRING_CHARS)


def get_random_string(length: int, allowed_chars: str=RANDOM_STRING_CHARS) -> str:
def get_random_string(length: int, allowed_chars: str = RANDOM_STRING_CHARS) -> str:
"""
Return a securely generated random string.

Expand All @@ -74,14 +86,14 @@ def get_random_string(length: int, allowed_chars: str=RANDOM_STRING_CHARS) -> st
return "".join(secrets.choice(allowed_chars) for i in range(length))


def pbkdf2(password: str, salt: str, iterations: int, dklen: int=0, digest: Optional['hashlib._Hash']=None):
def pbkdf2(password: str, salt: str, iterations: int, dklen: int = 0, digest: Optional[Hash] = None):
"""Return the hash of password using pbkdf2."""
if digest is None:
digest = hashlib.sha256
dklen = dklen or None
password = password.encode("utf-8", "strict")
salt = salt.encode("utf-8", "strict")
return hashlib.pbkdf2_hmac(digest().name, password, salt, iterations, dklen)

password_bytes = password.encode("utf-8", "strict")
salt_bytes = salt.encode("utf-8", "strict")
return hashlib.pbkdf2_hmac(digest().name, password_bytes, salt_bytes, iterations, dklen or None)


def constant_time_compare(val1, val2):
Expand Down
4 changes: 2 additions & 2 deletions lifecycle/lifecycle/auth/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def register_user_account(username: str, password: str) -> tables.User:
pass

user = LifecycleCache.record_mapper().create_from_dict(
tables.User,
tables.User,
{
"username": username,
"password": make_password(password),
Expand All @@ -66,7 +66,7 @@ def register_user_account(username: str, password: str) -> tables.User:
grant_permission(auth_subject, AuthScope.DEPLOY_JOB.value)

logger.info(f'User account created: {username}')
return user
return user_record


def change_user_password(username: str, old_password: str, new_password: str):
Expand Down
16 changes: 10 additions & 6 deletions lifecycle/lifecycle/auth/validate.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
from pathlib import Path


def validate_password(password: str):
for validator in [validate_password_length, validate_numeric_password, validate_common_password]:
validator(password)


def validate_password_length(password: str):
if len(password) < 8:
raise ValidationError("This password is too short. It must contain at least 8 characters.")



def validate_numeric_password(password: str):
if password.isdigit():
raise ValidationError("This password is entirely numeric.")



def validate_common_password(password: str):
# password list based on https://gist.github.com/roycewilliams/226886fd01572964e1431ac8afc999ce
# by Royce Williams
passwords_path: str = Path(__file__).resolve().parent / "common-passwords.txt"
passwords_path: Path = Path(__file__).resolve().parent / "common-passwords.txt"

with open(passwords_path, "rt", encoding="utf-8") as f:
passwords = {x.strip() for x in f}

if password.lower().strip() in passwords:
raise ValidationError("This password is too common.")


class ValidationError(Exception):
pass
Loading
Loading