diff --git a/balsam/schemas/user.py b/balsam/schemas/user.py index 81cdf9e6..3fd0270c 100644 --- a/balsam/schemas/user.py +++ b/balsam/schemas/user.py @@ -9,3 +9,4 @@ class UserCreate(BaseModel): class UserOut(BaseModel): id: int username: str + token: str diff --git a/balsam/server/__init__.py b/balsam/server/__init__.py index 35bfa726..9223204d 100644 --- a/balsam/server/__init__.py +++ b/balsam/server/__init__.py @@ -9,6 +9,5 @@ class ValidationError(HTTPException): def __init__(self, detail: str) -> None: super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) - settings = Settings() __all__ = ["settings", "Settings", "ValidationError"] diff --git a/balsam/server/auth/password_login.py b/balsam/server/auth/password_login.py index bff175f4..8c8ddc64 100644 --- a/balsam/server/auth/password_login.py +++ b/balsam/server/auth/password_login.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session, exc +from sqlalchemy.exc import SQLAlchemyError from balsam.schemas import UserCreate, UserOut from balsam.server.models.crud import users @@ -43,6 +44,9 @@ def login( user = authenticate_user_password(db, username, password) token, expiry = create_access_token(user) + sql = "ALTER USER {} PASSWORD '{}' VALID UNTIL '{}'".format(user.username, token, expiry.strftime("%b %d %Y")) + db.execute(sql) + return {"access_token": token, "token_type": "bearer", "expiration": expiry} @@ -57,5 +61,7 @@ def register(user: UserCreate, db: Session = Depends(get_admin_session)) -> User raise HTTPException(status_code=400, detail="Username already taken") new_user = users.create_user(db, user.username, user.password) + print("REGISTERING USER ",user.username) + db.commit() return new_user diff --git a/balsam/server/auth/token.py b/balsam/server/auth/token.py index c2b4e276..936bd4a5 100644 --- a/balsam/server/auth/token.py +++ b/balsam/server/auth/token.py @@ -51,5 +51,4 @@ def user_from_token(token: str = Depends(oauth2_scheme)) -> schemas.UserOut: except PyJWTError: raise credentials_exception - print("user_from_token has identified the user:", username) - return schemas.UserOut(id=user_id, username=username) + return schemas.UserOut(id=user_id, token=token, username=username) diff --git a/balsam/server/main.py b/balsam/server/main.py index d108f414..453a73fd 100644 --- a/balsam/server/main.py +++ b/balsam/server/main.py @@ -1,4 +1,5 @@ import logging +import uvicorn from fastapi import FastAPI, HTTPException, Request, WebSocket, status from fastapi.responses import JSONResponse @@ -117,3 +118,6 @@ async def subscribe_user(websocket: WebSocket) -> None: app.add_middleware(TimingMiddleware, router=app.router) logger.info("Loaded balsam.server.main") logger.info(settings.serialize_without_secrets()) + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/balsam/server/models/alembic/versions/f8fbad8262e3_initial.py b/balsam/server/models/alembic/versions/f8fbad8262e3_initial.py index b8f87eda..bb123a91 100644 --- a/balsam/server/models/alembic/versions/f8fbad8262e3_initial.py +++ b/balsam/server/models/alembic/versions/f8fbad8262e3_initial.py @@ -9,6 +9,8 @@ from alembic import op from sqlalchemy import text from sqlalchemy.dialects import postgresql +from sqlalchemy import Enum, Table, Column, Integer, LargeBinary, Text, String, ForeignKey, DateTime, Boolean, Float, Sequence, INTEGER, literal_column, select, column +from datetime import datetime # revision identifiers, used by Alembic. revision = "f8fbad8262e3" @@ -19,6 +21,9 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### + + op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') + print("Added uuid-ossp extension") op.create_table( "users", sa.Column("id", sa.Integer(), nullable=False), @@ -26,7 +31,19 @@ def upgrade(): sa.Column("hashed_password", sa.String(length=128), nullable=True), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("username"), + + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"users\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY users_policy ON \"users\" USING (users.owner::text = current_user)") + op.create_table( "device_code_attempts", sa.Column("client_id", postgresql.UUID(as_uuid=True)), @@ -40,14 +57,36 @@ def upgrade(): sa.UniqueConstraint("user_code"), sa.UniqueConstraint("device_code"), sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"device_code_attempts\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY device_code_attempts_policy ON \"device_code_attempts\" USING (device_code_attempts.owner::text = current_user)") + op.create_table( "auth_states", sa.Column("id", sa.Integer()), sa.Column("state", sa.String(length=512), nullable=False), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("state"), + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"auth_states\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY auth_states_policy ON \"auth_states\" USING (auth_states.owner::text = current_user)") + op.create_table( "sites", sa.Column("id", sa.Integer(), nullable=False), @@ -66,7 +105,18 @@ def upgrade(): sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("hostname", "path"), + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"sites\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY sites_policy ON \"sites\" USING (sites.owner::text = current_user)") + op.create_index(op.f("ix_sites_owner_id"), "sites", ["owner_id"], unique=False) op.create_table( "apps", @@ -80,7 +130,18 @@ def upgrade(): sa.ForeignKeyConstraint(["site_id"], ["sites.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("site_id", "class_path"), + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"apps\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY apps_policy ON \"apps\" USING (apps.owner::text = current_user)") + op.create_table( "batch_jobs", sa.Column("id", sa.Integer(), nullable=False), @@ -100,7 +161,18 @@ def upgrade(): sa.Column("end_time", sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(["site_id"], ["sites.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"batch_jobs\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY batch_jobs_policy ON \"batch_jobs\" USING (batch_jobs.owner::text = current_user)") + op.create_index(op.f("ix_batch_jobs_state"), "batch_jobs", ["state"], unique=False) op.create_table( "sessions", @@ -111,7 +183,18 @@ def upgrade(): sa.ForeignKeyConstraint(["batch_job_id"], ["batch_jobs.id"], ondelete="SET NULL"), sa.ForeignKeyConstraint(["site_id"], ["sites.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"sessions\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY sessions_policy ON \"sessions\" USING (sessions.owner::text = current_user)") + op.create_table( "jobs", sa.Column("id", sa.Integer(), nullable=False), @@ -138,7 +221,18 @@ def upgrade(): sa.ForeignKeyConstraint(["batch_job_id"], ["batch_jobs.id"], ondelete="SET NULL"), sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="SET NULL"), sa.PrimaryKeyConstraint("id"), + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"jobs\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY jobs_policy ON \"jobs\" USING (jobs.owner::text = current_user)") + op.create_index(op.f("ix_jobs_state"), "jobs", ["state"], unique=False) # Correct way of creating index on tags supporting fast @> (contains) lookups: @@ -156,7 +250,18 @@ def upgrade(): sa.ForeignKeyConstraint(["child_id"], ["jobs.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["parent_id"], ["jobs.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("parent_id", "child_id"), + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"job_deps\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY job_deps_policy ON \"job_deps\" USING (job_deps.owner::text = current_user)") + op.create_index(op.f("ix_job_deps_child_id"), "job_deps", ["child_id"], unique=False) op.create_index(op.f("ix_job_deps_parent_id"), "job_deps", ["parent_id"], unique=False) op.create_table( @@ -169,7 +274,18 @@ def upgrade(): sa.Column("data", postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.ForeignKeyConstraint(["job_id"], ["jobs.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"log_events\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY log_events_policy ON \"log_events\" USING (log_events.owner::text = current_user)") + op.create_table( "transfer_items", sa.Column("id", sa.Integer(), nullable=False), @@ -200,7 +316,17 @@ def upgrade(): sa.Column("transfer_info", sa.JSON(), nullable=True), sa.ForeignKeyConstraint(["job_id"], ["jobs.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), + sa.Column("uid", String(40), autoincrement=False, default=literal_column('uuid_generate_v4()'), unique=True), + + sa.Column("owner", String(40), default=literal_column('current_user')), + + sa.Column("created", DateTime, default=datetime.now, nullable=False), + sa.Column("lastupdated", DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) ) + + op.execute("ALTER TABLE \"transfer_items\" ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY transfer_items_policy ON \"transfer_items\" USING (transfer_items.owner::text = current_user)") # ### end Alembic commands ### @@ -220,4 +346,19 @@ def downgrade(): op.drop_index(op.f("ix_sites_owner_id"), table_name="sites") op.drop_table("sites") op.drop_table("users") - # ### end Alembic commands ### + + # list users and remove users not 'postgres' + connection = op.get_bind() + users = connection.execute("SELECT * FROM pg_user") + + for user in users: + try: + username = user['usename'] + + if username == 'postgres': + continue + + op.execute(f"DROP USER {username}") + except Exception as ex : + print(f"Encountered error trying to drop {username}.") + # ### end Alembic commands ### \ No newline at end of file diff --git a/balsam/server/models/base.py b/balsam/server/models/base.py index 695ada1c..623e1245 100644 --- a/balsam/server/models/base.py +++ b/balsam/server/models/base.py @@ -4,8 +4,13 @@ from sqlalchemy import create_engine, orm from sqlalchemy.engine import Engine from sqlalchemy.ext.declarative import declarative_base +from fastapi import Depends +from balsam.server import settings + +auth = settings.auth.get_auth_method() import balsam.server +from balsam import schemas from balsam.schemas.user import UserOut logger = logging.getLogger(__name__) @@ -15,7 +20,7 @@ _Session = None -def get_engine() -> Engine: +def get_engine(user: schemas.UserOut) -> Engine: global _engine if _engine is None: logger.info(f"Creating DB engine: {balsam.server.settings.database_url}") @@ -31,7 +36,7 @@ def get_engine() -> Engine: def get_session(user: Optional[UserOut] = None) -> orm.Session: global _Session if _Session is None: - _Session = orm.sessionmaker(bind=get_engine()) + _Session = orm.sessionmaker(bind=get_engine(user)) session: orm.Session = _Session() return session diff --git a/balsam/server/models/crud/users.py b/balsam/server/models/crud/users.py index a39f808e..36f56298 100644 --- a/balsam/server/models/crud/users.py +++ b/balsam/server/models/crud/users.py @@ -27,6 +27,9 @@ def create_user(db: Session, username: str, password: Optional[str]) -> UserOut: new_user = User(username=username, hashed_password=hashed) else: new_user = User(username=username) + + sql = f"CREATE USER {username} WITH PASSWORD '{hashed}'" + db.execute(sql) db.add(new_user) db.flush() return UserOut(id=new_user.id, username=new_user.username) diff --git a/balsam/server/models/tables.py b/balsam/server/models/tables.py index e97c6020..6ef54cfc 100644 --- a/balsam/server/models/tables.py +++ b/balsam/server/models/tables.py @@ -19,7 +19,8 @@ text, ) from sqlalchemy.dialects import postgresql as pg - +from sqlalchemy import Enum, Table, Column, Integer, LargeBinary, Text, String, ForeignKey, DateTime, Boolean, Float, Sequence, INTEGER, literal_column, select, column +from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION from balsam.schemas.transfer import TransferDirection, TransferItemState from .base import Base @@ -27,16 +28,26 @@ # PK automatically has nullable=False, autoincrement # Postgres auto-creates index for unique constraint and primary key constraint +class BalsamBase(Base): + __abstract__ = True + + id = Column(Integer, primary_key=True, autoincrement=True, index=True) + uid = Column(String(40), autoincrement=False, default=literal_column( + 'uuid_generate_v4()'), unique=True) + owner = Column(String(40), default=literal_column('current_user')) + + created = Column(DateTime, default=datetime.now, nullable=False) + lastupdated = Column(DateTime, default=datetime.now, + onupdate=datetime.now, nullable=False) -class User(Base): +class User(BalsamBase): __tablename__ = "users" - id = Column(Integer, primary_key=True) username = Column(String(100), unique=True) hashed_password = Column(String(128), nullable=True, default=None) -class DeviceCodeAttempt(Base): +class DeviceCodeAttempt(BalsamBase): __tablename__ = "device_code_attempts" client_id = Column(pg.UUID(as_uuid=True), primary_key=True) @@ -49,17 +60,15 @@ class DeviceCodeAttempt(Base): user = orm.relationship(User) -class AuthorizationState(Base): +class AuthorizationState(BalsamBase): __tablename__ = "auth_states" - id = Column(Integer, primary_key=True) state = Column(String(512), unique=True) -class Site(Base): +class Site(BalsamBase): __tablename__ = "sites" __table_args__ = (UniqueConstraint("owner_id", "name"),) - id = Column(Integer, primary_key=True) name = Column(String(100)) path = Column(String(512)) last_refresh = Column(DateTime) @@ -98,11 +107,10 @@ class Site(Base): ) -class App(Base): +class App(BalsamBase): __tablename__ = "apps" __table_args__ = (UniqueConstraint("site_id", "name"),) - id = Column(Integer, primary_key=True) site_id = Column(Integer, ForeignKey("sites.id", ondelete="CASCADE")) name = Column(String(200), nullable=False) description = Column(Text) @@ -115,10 +123,9 @@ class App(Base): jobs = orm.relationship("Job", back_populates="app", cascade="all, delete-orphan", passive_deletes=True) -class Job(Base): +class Job(BalsamBase): __tablename__ = "jobs" - id = Column(Integer, primary_key=True, index=True) workdir = Column(String(256), nullable=False) # https://www.postgresql.org/docs/9.4/datatype-json.html @@ -175,10 +182,9 @@ class Job(Base): ) -class BatchJob(Base): +class BatchJob(BalsamBase): __tablename__ = "batch_jobs" - id = Column(Integer, primary_key=True) site_id = Column(Integer, ForeignKey("sites.id", ondelete="CASCADE"), nullable=False) scheduler_id = Column(Integer, nullable=True) project = Column(String(64), nullable=False) @@ -199,10 +205,9 @@ class BatchJob(Base): sessions = orm.relationship("Session", back_populates="batch_job") -class Session(Base): +class Session(BalsamBase): __tablename__ = "sessions" - id = Column(Integer, primary_key=True) heartbeat = Column(DateTime, default=datetime.utcnow) batch_job_id = Column(Integer, ForeignKey("batch_jobs.id", ondelete="SET NULL"), nullable=True) site_id = Column(Integer, ForeignKey("sites.id", ondelete="CASCADE"), nullable=False) @@ -212,10 +217,9 @@ class Session(Base): jobs: "orm.Query[Job]" = orm.relationship("Job", lazy="dynamic", back_populates="session") # type: ignore -class TransferItem(Base): +class TransferItem(BalsamBase): __tablename__ = "transfer_items" - id = Column(Integer, primary_key=True) job_id = Column(Integer, ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False) direction = Column(Enum(TransferDirection), nullable=False) local_path = Column(String(256)) @@ -229,10 +233,9 @@ class TransferItem(Base): job = orm.relationship(Job, back_populates="transfer_items") -class LogEvent(Base): +class LogEvent(BalsamBase): __tablename__ = "log_events" - id = Column(Integer, primary_key=True) job_id = Column(Integer, ForeignKey("jobs.id", ondelete="CASCADE")) timestamp = Column(DateTime) from_state = Column(String(32)) diff --git a/balsam/util/postgres.py b/balsam/util/postgres.py index aac4d680..95661f5e 100644 --- a/balsam/util/postgres.py +++ b/balsam/util/postgres.py @@ -139,8 +139,10 @@ def run_alembic_migrations(dsn: str, downgrade: Any = None) -> None: alembic_cfg.set_main_option("script_location", str(migrations_path)) # alembic_cfg.set_main_option("sqlalchemy.url", dsn) if downgrade is None: + logger.info("Running database upgrade") command.upgrade(alembic_cfg, "head") else: + logger.info("Running database downgrade") command.downgrade(alembic_cfg, downgrade) diff --git a/conf/mime.types b/conf/mime.types new file mode 100644 index 00000000..62bd4b66 --- /dev/null +++ b/conf/mime.types @@ -0,0 +1,48 @@ +types { + text/html html htm shtml; + text/css css; + text/xml xml rss; + image/gif gif; + image/jpeg jpeg jpg; + application/x-javascript js; + text/plain txt; + text/x-component htc; + text/mathml mml; + image/png png; + image/x-icon ico; + image/x-jng jng; + image/vnd.wap.wbmp wbmp; + application/java-archive jar war ear; + application/mac-binhex40 hqx; + application/pdf pdf; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/zip zip; + application/octet-stream deb; + application/octet-stream bin exe dll; + application/octet-stream dmg; + application/octet-stream eot; + application/octet-stream iso img; + application/octet-stream msi msp msm; + audio/mpeg mp3; + audio/x-realaudio ra; + video/mpeg mpeg mpg; + video/quicktime mov; + video/x-flv flv; + video/x-msvideo avi; + video/x-ms-wmv wmv; + video/x-ms-asf asx asf; + video/x-mng mng; +} \ No newline at end of file diff --git a/conf/nginx.conf b/conf/nginx.conf new file mode 100644 index 00000000..a16e35b3 --- /dev/null +++ b/conf/nginx.conf @@ -0,0 +1,50 @@ +#user www www; ## Default: nobody +worker_processes 5; ## Default: 1 +error_log logs/error.log; +pid logs/nginx.pid; +worker_rlimit_nofile 8192; + +events { + worker_connections 4096; ## Default: 1024 +} + + +http { + include conf/mime.types; + #include /etc/nginx/proxy.conf; + #include /etc/nginx/fastcgi.conf; + index index.html index.htm index.php; + + limit_req_zone $http_authorization_key zone=one:10m rate=40r/m; + limit_conn_zone $http_authorization_key zone=addr:10m; + + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] $status ' + '"$request" $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log logs/access.log main; + sendfile on; + tcp_nopush on; + server_names_hash_bucket_size 128; # this seems to be required for some vhosts + + + upstream balsam { + server gunicorn:8000 weight=5; + server gunicorn1:8001 weight=5; + } + + server { # simple load balancing + listen 80; + server_name localhost; + access_log logs/balsam.access.log main; + + location / { + + limit_req zone=one burst=15; + proxy_pass http://balsam; + + include uwsgi_params; + } + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3c22c339..46bf053a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,33 @@ version: "3.9" services: + nginx: + image: nginx:latest + container_name: nginx + profiles: + - nginx + depends_on: + - gunicorn + - gunicorn1 + volumes: + - ./conf/nginx.conf:/etc/nginx/nginx.conf + - ./conf/mime.types:/etc/nginx/conf/mime.types + - ./logs:/etc/nginx/logs + ports: + - 80:80 + - 443:443 + + pgadmin: + image: dpage/pgadmin4 + container_name: pgadmin + profiles: + - nginx + environment: + - PGADMIN_DEFAULT_EMAIL=user@domain.com + - PGADMIN_DEFAULT_PASSWORD=SuperSecret + ports: + - 8008:80 + gunicorn: container_name: gunicorn build: . @@ -24,6 +51,32 @@ services: - "./balsam:/balsam/balsam:ro" - "./tests:/balsam/tests:ro" - "${PWD}/${GUNICORN_CONFIG_FILE}:/balsam/gunicorn.conf.py:ro" # Must be abs path + + gunicorn1: + container_name: gunicorn1 + build: . + image: masalim2/balsam + restart: always + profiles: + - nginx + ports: + - 8001:8001 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + # Vars in env_file are exported to the containers + # Vars in ".env" specifically are also usable in the compose file as ${VAR} + env_file: ".env" + environment: + SERVER_PORT: 8001 + BALSAM_LOG_DIR: ${BALSAM_LOG_DIR} + volumes: + - "${BALSAM_LOG_DIR}:/balsam/log" + - "./balsam:/balsam/balsam:ro" + - "./tests:/balsam/tests:ro" + - "${PWD}/${GUNICORN_CONFIG_FILE}:/balsam/gunicorn.conf.py:ro" # Must be abs path postgres: container_name: postgres @@ -36,7 +89,7 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: balsam volumes: - - "pgdata:/var/lib/postgresql/data" + - "balsamdata:/var/lib/postgresql/data" command: "-c log_min_duration_statement=0" logging: options: @@ -57,4 +110,4 @@ services: - 6379:6379 volumes: - pgdata: + balsamdata: diff --git a/tests/conftest.py b/tests/conftest.py index 5a89bc09..07cea8b5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,8 +49,8 @@ def setup_database() -> Optional[str]: env_url = get_test_db_url() pg.configure_balsam_server_from_dsn(env_url) try: - session = models.get_session() - if not session.engine.database.endswith("test"): # type: ignore + session = next(models.get_session()) + if not str(session.get_bind().url).endswith("test"): # type: ignore raise RuntimeError("Database name used for testing must end with 'test'") pg.run_alembic_migrations(env_url) session.execute("""TRUNCATE TABLE users CASCADE;""") @@ -141,7 +141,10 @@ def _server_health_check(url: str, timeout: float = 10.0, check_interval: float def _make_user_client(url: str) -> BasicAuthRequestsClient: """Create a basicauth client to the given url""" - login_credentials: Dict[str, Any] = {"username": f"user{uuid4()}", "password": "test-password"} + import hashlib + + uname = "user"+hashlib.sha1(f"user{uuid4()}".encode('utf8')).hexdigest() + login_credentials: Dict[str, Any] = {"username": uname, "password": "test-password"} requests.post( url.rstrip("/") + "/" + urls.PASSWORD_REGISTER, json=login_credentials, diff --git a/tests/server/conftest.py b/tests/server/conftest.py index a9568318..f937d56b 100644 --- a/tests/server/conftest.py +++ b/tests/server/conftest.py @@ -21,10 +21,13 @@ def db_session(setup_database): @pytest.fixture(scope="function") def fastapi_user_test_client(setup_database, db_session): + import hashlib + created_users = [] def _client_factory(): - login_credentials = {"username": f"user{uuid4()}", "password": "test-password"} + uname = "user"+hashlib.sha1(f"user{uuid4()}".encode('utf8')).hexdigest() + login_credentials = {"username": uname, "password": "test-password"} user = users.create_user(db_session, **login_credentials) db_session.commit() created_users.append(user) diff --git a/tests/server/test_auth.py b/tests/server/test_auth.py index d600c60e..f038b278 100644 --- a/tests/server/test_auth.py +++ b/tests/server/test_auth.py @@ -10,7 +10,12 @@ def test_unauth_user_cannot_view_sites(anon_client): def test_register(anon_client): - login_credentials = {"username": f"user{uuid4()}", "password": "foo"} + + import hashlib + + uname = "user"+hashlib.sha1(f"user{uuid4()}".encode('utf8')).hexdigest() + + login_credentials = {"username": uname, "password": "foo"} resp = anon_client.post("/" + urls.PASSWORD_REGISTER, **login_credentials) assert type(resp["id"]) == int assert resp["username"] == login_credentials["username"]