-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(backend): Add Project data model and creation API endpoint
Integration tests are missing pending a suitable mocking strategy.
- Loading branch information
Showing
9 changed files
with
346 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
41 changes: 41 additions & 0 deletions
41
backend/src/jobq_server/alembic/versions/2837c7c54f35_initial_schema.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
"""Initial schema | ||
Revision ID: 2837c7c54f35 | ||
Revises: | ||
Create Date: 2024-10-31 11:27:32.242586 | ||
""" | ||
from alembic import op | ||
import sqlalchemy as sa | ||
import sqlmodel.sql.sqltypes | ||
|
||
|
||
# revision identifiers, used by Alembic. | ||
revision = '2837c7c54f35' | ||
down_revision = None | ||
branch_labels = None | ||
depends_on = None | ||
|
||
|
||
def upgrade(): | ||
# ### commands auto generated by Alembic - please adjust! ### | ||
op.create_table('project', | ||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), | ||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), | ||
sa.Column('cluster_queue', sqlmodel.sql.sqltypes.AutoString(), nullable=True), | ||
sa.Column('local_queue', sqlmodel.sql.sqltypes.AutoString(), nullable=True), | ||
sa.Column('namespace', sqlmodel.sql.sqltypes.AutoString(), nullable=True), | ||
sa.Column('id', sa.Uuid(), nullable=False), | ||
sa.PrimaryKeyConstraint('id') | ||
) | ||
op.create_index(op.f('ix_project_description'), 'project', ['description'], unique=False) | ||
op.create_index(op.f('ix_project_name'), 'project', ['name'], unique=True) | ||
# ### end Alembic commands ### | ||
|
||
|
||
def downgrade(): | ||
# ### commands auto generated by Alembic - please adjust! ### | ||
op.drop_index(op.f('ix_project_name'), table_name='project') | ||
op.drop_index(op.f('ix_project_description'), table_name='project') | ||
op.drop_table('project') | ||
# ### end Alembic commands ### |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import logging | ||
|
||
from fastapi import APIRouter | ||
from kubernetes import client | ||
from sqlmodel import select | ||
|
||
from jobq_server.db import Project, ProjectCreate, ProjectPublic | ||
from jobq_server.dependencies import DBSessionDep, KubernetesDep, KueueDep | ||
from jobq_server.utils.kueue import ClusterQueue, ClusterQueueSpec, LocalQueue | ||
|
||
router = APIRouter() | ||
|
||
|
||
@router.get("/") | ||
async def list_projects(db: DBSessionDep): | ||
return db.exec(select(Project)).all() | ||
|
||
|
||
@router.post("/", status_code=201) | ||
async def create_project( | ||
project: ProjectCreate, | ||
db: DBSessionDep, | ||
k8s: KubernetesDep, | ||
kueue: KueueDep, | ||
) -> ProjectPublic: | ||
# Create namespace if it doesn't exist | ||
ns, created = k8s.ensure_namespace(project.namespace) | ||
if created: | ||
logging.info(f"Created Kubernetes namespace {ns.metadata.name}") | ||
|
||
# Create cluster queue if it doesn't exist | ||
cluster_queue = kueue.get_cluster_queue(project.cluster_queue) | ||
if cluster_queue is None: | ||
default_spec = { | ||
"namespaceSelector": {}, | ||
"preemption": { | ||
"reclaimWithinCohort": "Any", | ||
"borrowWithinCohort": { | ||
"policy": "LowerPriority", | ||
"maxPriorityThreshold": 100, | ||
}, | ||
"withinClusterQueue": "LowerPriority", | ||
}, | ||
"resourceGroups": [ | ||
{ | ||
"coveredResources": ["cpu", "memory"], | ||
"flavors": [ | ||
{ | ||
"name": "default-flavor", | ||
"resources": [ | ||
{"name": "cpu", "nominalQuota": 4}, | ||
{"name": "memory", "nominalQuota": 6}, | ||
], | ||
} | ||
], | ||
} | ||
], | ||
} | ||
cluster_queue = ClusterQueue( | ||
metadata=client.V1ObjectMeta(name=project.cluster_queue), | ||
spec=ClusterQueueSpec.model_validate(default_spec), | ||
) | ||
kueue.create_cluster_queue(cluster_queue) | ||
logging.info(f"Created cluster queue {project.cluster_queue!r}") | ||
|
||
# Create local queue if it doesn't exist | ||
local_queue = kueue.get_local_queue(project.local_queue, project.namespace) | ||
if local_queue is None: | ||
local_queue = LocalQueue( | ||
metadata=client.V1ObjectMeta( | ||
name=project.local_queue, namespace=project.namespace | ||
), | ||
spec={"clusterQueue": project.cluster_queue}, | ||
) | ||
|
||
kueue.create_local_queue(local_queue) | ||
logging.info( | ||
f"Created user queue {project.local_queue!r} in namespace {project.namespace!r}" | ||
) | ||
|
||
# TODO: Apply finalizers to Kubernetes resources to prevent deletion while the project exists | ||
|
||
db_obj = Project.model_validate(project) | ||
db.add(db_obj) | ||
db.commit() | ||
db.refresh(db_obj) | ||
return db_obj |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
from kubernetes import client | ||
|
||
from jobq_server.services.k8s import KubernetesService | ||
from jobq_server.utils.kueue import ClusterQueue, LocalQueue | ||
|
||
|
||
class KueueService: | ||
def __init__(self, k8s: KubernetesService): | ||
self.k8s = k8s | ||
self.custom_obj_api = client.CustomObjectsApi() | ||
|
||
def get_cluster_queue(self, name: str) -> ClusterQueue | None: | ||
"""Get a cluster queue by name. | ||
Returns | ||
------- | ||
ClusterQueue | None | ||
The cluster queue if it exists, otherwise None. | ||
""" | ||
try: | ||
k8s_obj = self.custom_obj_api.get_cluster_custom_object( | ||
"kueue.x-k8s.io", | ||
"v1beta1", | ||
"clusterqueues", | ||
name, | ||
) | ||
return ClusterQueue.model_validate(k8s_obj) | ||
except client.ApiException as e: | ||
if e.status == 404: | ||
return None | ||
raise | ||
|
||
def get_local_queue(self, name: str, namespace: str) -> LocalQueue | None: | ||
"""Get a local queue by name and namespace. | ||
Returns | ||
------- | ||
LocalQueue | None | ||
The local queue if it exists, otherwise None. | ||
""" | ||
try: | ||
k8s_obj = self.custom_obj_api.get_namespaced_custom_object( | ||
"kueue.x-k8s.io", | ||
"v1beta1", | ||
namespace, | ||
"localqueues", | ||
name, | ||
) | ||
return LocalQueue.model_validate(k8s_obj) | ||
except client.ApiException as e: | ||
if e.status == 404: | ||
return None | ||
raise | ||
|
||
def create_local_queue(self, queue: LocalQueue) -> None: | ||
_ = self.k8s.ensure_namespace(queue.metadata.namespace) | ||
|
||
data = { | ||
"apiVersion": "kueue.x-k8s.io/v1beta1", | ||
"kind": "LocalQueue", | ||
**queue.model_dump(), | ||
} | ||
return self.custom_obj_api.create_namespaced_custom_object( | ||
"kueue.x-k8s.io", | ||
"v1beta1", | ||
queue.metadata.namespace, | ||
"localqueues", | ||
body=data, | ||
) | ||
|
||
def create_cluster_queue(self, queue: ClusterQueue) -> None: | ||
data = { | ||
"apiVersion": "kueue.x-k8s.io/v1beta1", | ||
"kind": "ClusterQueue", | ||
**queue.model_dump(), | ||
} | ||
return self.custom_obj_api.create_cluster_custom_object( | ||
"kueue.x-k8s.io", | ||
"v1beta1", | ||
"clusterqueues", | ||
body=data, | ||
) |
Oops, something went wrong.