Skip to content

Commit

Permalink
List jobs tweaks (#45)
Browse files Browse the repository at this point in the history
* testing keeping flux-uri set
* get rid of mini
* print job spec
* refactor multi-user auth to not use pam (instead OAuth2 similar)
* submit needs to be string
* total refactor to use database

I am not happy with the current multi-user setup that tries to use pam,
and still is not using good practices like oauth2/jwt tokens. I also
do not like the idea of maintaining two modes of operation - one through
a flux user and one via sudo running the server and launching jobs as the
user. Instead, I think the flux restful server should be in charge of
authenticating users, and using a local database that can be created
by an entity like the flux operator, and then a secret to encrypt all
communication. I have a lot of work to update tests, examples, and docs,
and then I need to test with the flux operator, but this should at least
be the start of the crux of work. Next I will work on fixing up the
Python client to do the proper back and forth for an oauth2 token.

* working on testing
* update docs
* ensure we have secret key in testing container
* add migrations directory
* update for launcher case
* ensure we include completion fields

Signed-off-by: vsoch <[email protected]>
  • Loading branch information
vsoch authored Mar 5, 2023
1 parent a143c11 commit 32dd593
Show file tree
Hide file tree
Showing 60 changed files with 1,530 additions and 504 deletions.
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
flux-restful.db
__pycache__
.devcontainer
.git
migrations/versions/*
13 changes: 6 additions & 7 deletions .github/workflows/python-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
needs: [prepare-container]
services:
api:
image: ghcr.io/flux-framework/flux-restful-api:latest
image: ghcr.io/flux-framework/flux-restful-api:test
ports:
- 5000:5000
env:
Expand All @@ -44,18 +44,17 @@ jobs:
env:
INSTALL_BRANCH: ${{ needs.prepare-container.outputs.branch }}
INSTALL_REPO: ${{ github.repository }}
FLUX_USER: fluxuser
FLUX_TOKEN: "12345"
FLUX_REQUIRE_AUTH: true
image: ghcr.io/flux-framework/flux-restful-api:latest
image: ghcr.io/flux-framework/flux-restful-api:test
ports:
- 5000:5000
steps:
- uses: actions/checkout@v3
- name: Run tests with auth
run: |
cd clients/python
pip install -e .[all]
export FLUX_USER=fluxuser
export FLUX_TOKEN=12345
export FLUX_REQUIRE_AUTH=true
export FLUX_SECRET_KEY=notsecrethoo
cd clients/python
pip install -e .[all]
pytest -xs flux_restful_client/tests/test_api.py
28 changes: 14 additions & 14 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ jobs:
runs-on: ubuntu-latest
needs: [prepare-container]
container:
image: ghcr.io/flux-framework/flux-restful-api:latest
image: ghcr.io/flux-framework/flux-restful-api:test
ports:
- 5000
env:
INSTALL_BRANCH: ${{ needs.prepare-container.outputs.branch }}
INSTALL_REPO: ${{ github.repository }}
FLUX_SECRET_KEY: notsosecrethoo
FLUX_USER: fluxuser
FLUX_TOKEN: "12345"
steps:
- uses: actions/checkout@v3
- name: Install Dependencies (in case changes)
Expand All @@ -32,20 +35,17 @@ jobs:
# Tests for the API with auth disabled
flux start pytest -xs tests/test_api.py
# Tests for the API with single user auth
export FLUX_REQUIRE_AUTH=true
export TEST_AUTH=true
# Create main user in database
export FLUX_USER=fluxuser
export FLUX_TOKEN=12345
flux start pytest -xs tests/test_api.py
# Tests for the API with multi-user auth, but fail because user not created
unset FLUX_USER
unset FLUX_TOKEN
export TEST_PAM_AUTH=true
export TEST_PAM_AUTH_FAIL=true
export FLUX_ENABLE_PAM=true
flux start pytest -xs tests/test_api.py
alembic revision --autogenerate -m "Create intital tables"
alembic upgrade head
python3 app/db/init_db.py init
# TODO how to test pam in this mode?
# We would need to start flux as flux and run tests as a user
# Require auth and shared secret key
export FLUX_SECRET_KEY=notsosecrethoo
export FLUX_REQUIRE_AUTH=true
export TEST_AUTH=true
flux start pytest -xs tests/test_api.py
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
flux-restful.db
env
.env
__pycache__
Expand Down
17 changes: 6 additions & 11 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,14 @@ FROM fluxrm/flux-sched:focal

# This must be set to work (username / token set won't trigger it alone)
ARG use_auth
ARG user="fluxuser"
ARG token="12345"
ARG port="5000"
ARG host="0.0.0.0"
ARG workers="1"
LABEL maintainer="Vanessasaurus <@vsoch>"

ENV FLUX_USER=${user}
ENV FLUX_TOKEN=${token}
ENV FLUX_USER=${user:-fluxuser}
ENV FLUX_TOKEN=${token:-12345}
ENV FLUX_REQUIRE_AUTH=${use_auth}
ENV PORT=${port}
ENV HOST=${host}
ENV WORKERS=${workers}
ENV PORT=${port:-5000}
ENV HOST=${host:-0.0.0.0}
ENV WORKERS=${workers:-1}

USER root
RUN apt-get update
Expand All @@ -26,7 +21,7 @@ EXPOSE ${port}
ENV PYTHONPATH=/usr/lib/flux/python3.8:/code

# For easier Python development, and install time for timed commands
RUN python3 -m pip install -r /requirements.txt && \
RUN pip install -r /requirements.txt && \
apt-get update && apt-get install -y time && \
apt-get clean && \
apt-get autoremove && \
Expand Down
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,6 @@ tool to generate a contributors graphic below.
<!-- ALL-CONTRIBUTORS-LIST:END -->


## TODO

- Interface view with nice job table
- We can put additional assets for the server in [data](data), not sure what those are yet!

#### Release

SPDX-License-Identifier: LGPL-3.0
Expand Down
105 changes: 105 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# A generic, single database configuration.

[alembic]
# path to migration scripts
script_location = migrations

# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s

# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .

# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =

# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false

# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false

# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions

# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.

# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8

sqlalchemy.url = sqlite:///./flux-restful.db


[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples

# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME

# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
22 changes: 20 additions & 2 deletions app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import logging
import os
import re
import secrets
import shlex
import string

from pydantic import BaseSettings

Expand Down Expand Up @@ -58,23 +60,39 @@ def parse_option_flags(flags, prefix="-o"):
return values


def generate_secret_key(length=32):
"""
Generate a secret key to encrypt, if one not provided.
"""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for i in range(length))


class Settings(BaseSettings):
"""
Basic settings and defaults for the Flux RESTFul API
"""

app_name: str = "Flux RESTFul API"
api_version: str = "v1"

# These map to envars, e.g., FLUX_USER
has_gpus: bool = get_bool_envar("FLUX_HAS_GPUS")
enable_pam: bool = get_bool_envar("FLUX_ENABLE_PAM")

# Assume there is at least one node!
flux_nodes: int = get_int_envar("FLUX_NUMBER_NODES", 1)
require_auth: bool = get_bool_envar("FLUX_REQUIRE_AUTH")

# If you change this, also change in alembic.ini
db_file: str = "sqlite:///./flux-restful.db"
flux_user: str = os.environ.get("FLUX_USER")
flux_token: str = os.environ.get("FLUX_TOKEN")
require_auth: bool = get_bool_envar("FLUX_REQUIRE_AUTH")
secret_key: str = os.environ.get("FLUX_SECRET_KEY") or generate_secret_key()

# Expires in 10 hours
access_token_expires_minutes: int = get_int_envar(
"FLUX_ACCESS_TOKEN_EXPIRES_MINUTES", 600
)

# Default server option flags
option_flags: dict = get_option_flags("FLUX_OPTION_FLAGS")
Expand Down
33 changes: 33 additions & 0 deletions app/core/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from datetime import datetime, timedelta
from typing import Any, Union

from jose import jwt
from passlib.context import CryptContext

from app.core.config import settings

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


ALGORITHM = "HS256"


def create_access_token(
subject: Union[str, Any], expires_delta: timedelta = None
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.access_token_expires_minutes
)
to_encode = {"exp": expire, "sub": str(subject)}
return jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM)


def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)


def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
1 change: 1 addition & 0 deletions app/crud/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .user import user # noqa
66 changes: 66 additions & 0 deletions app/crud/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union

from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy.orm import Session

from app.db.base_class import Base

ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)


class ModelBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType]):
"""
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
**Parameters**
* `model`: A SQLAlchemy model class
* `schema`: A Pydantic model (schema) class
"""
self.model = model

def get(self, db: Session, id: Any) -> Optional[ModelType]:
return db.query(self.model).filter(self.model.id == id).first()

def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
return db.query(self.model).offset(skip).limit(limit).all()

def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data) # type: ignore
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj

def update(
self,
db: Session,
*,
db_obj: ModelType,
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> ModelType:
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj

def remove(self, db: Session, *, id: int) -> ModelType:
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj
Loading

0 comments on commit 32dd593

Please sign in to comment.