diff --git a/jupyterhub/alembic/versions/3c2384c5aae1_add_from_config_column_to_the_services_.py b/jupyterhub/alembic/versions/3c2384c5aae1_add_from_config_column_to_the_services_.py index 558818b0..26b832a3 100644 --- a/jupyterhub/alembic/versions/3c2384c5aae1_add_from_config_column_to_the_services_.py +++ b/jupyterhub/alembic/versions/3c2384c5aae1_add_from_config_column_to_the_services_.py @@ -35,7 +35,7 @@ def upgrade(): if 'services' in tables: op.add_column( 'services', - sa.Column('from_config', sa.Boolean, nullable=True, default=True), + sa.Column('from_config', sa.Boolean, default=True), ) op.execute('UPDATE services SET from_config = true') for item in COL_DATA: diff --git a/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py b/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py index 31aede31..6be31b5f 100644 --- a/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py +++ b/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py @@ -32,10 +32,12 @@ def access_scopes(oauth_client: orm.OAuthClient, db: Session): if spawner: scopes.add(f"access:servers!server={spawner.user.name}/{spawner.name}") else: - statement = f"SELECT * FROM services WHERE oauth_client_id = '{oauth_client.identifier}'" - service = db.execute(text(statement)).fetchall() + statement = "SELECT * FROM services WHERE oauth_client_id = :identifier" + service = db.execute( + text(statement), {"identifier": oauth_client.identifier} + ).fetchall() if len(service) > 0: - scopes.add(f"access:services!service={service.name}") + scopes.add(f"access:services!service={service[0].name}") return frozenset(scopes) diff --git a/jupyterhub/apihandlers/services.py b/jupyterhub/apihandlers/services.py index 7257fac8..82db46bc 100644 --- a/jupyterhub/apihandlers/services.py +++ b/jupyterhub/apihandlers/services.py @@ -63,9 +63,21 @@ class ServiceAPIHandler(APIHandler): note='generated at runtime' ) if new_service.managed or new_service.url: - self.app.start_service(service_name, new_service) + service_status = self.app.start_service(service_name, new_service) + if not service_status: + self.log.error( + 'Failed to start service %s', + service_name, + exc_info=True, + ) self.app.toggle_service_health_check() + if new_service.oauth_no_confirm: + oauth_no_confirm_list = self.settings.get('oauth_no_confirm_list') + msg = f"Allowing service {new_service.name} to complete OAuth without confirmation on an authorization web page" + self.log.warning(msg) + oauth_no_confirm_list.add(new_service.oauth_client_id) + return new_service @needs_scope('admin:services') @@ -136,6 +148,10 @@ class ServiceAPIHandler(APIHandler): if orm_server is not None: self.db.delete(orm_server) + if service.oauth_no_confirm: + oauth_no_confirm_list = self.settings.get('oauth_no_confirm_list') + oauth_no_confirm_list.remove(service.oauth_client_id) + self.db.delete(orm_service) self.db.commit() diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 45c8cc3a..b61686ca 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -2375,7 +2375,7 @@ class JupyterHub(Application): ) traits = service.traits(input=True) for key in traits: - value = getattr(orm_service, key, None) + value = orm_service.get_column(key) if value is not None: setattr(service, key, value) @@ -2448,6 +2448,7 @@ class JupyterHub(Application): if key not in traits: raise AttributeError("No such service field: %s" % key) setattr(service, key, value) + orm_service.update_column(key, value) if service.api_token: self.service_tokens[service.api_token] = service.name @@ -3238,48 +3239,6 @@ class JupyterHub(Application): if self._check_services_health_callback is not None: self._check_services_health_callback.stop() - async def _start_service(self, service_name, service, ssl_context): - for service_name, service in self._service_map.items(): - msg = f'{service_name} at {service.url}' if service.url else service_name - if service.managed: - self.log.info("Starting managed service %s", msg) - try: - await service.start() - except Exception as e: - self.log.critical( - "Failed to start service %s", service_name, exc_info=True - ) - self.exit(1) - else: - self.log.info("Adding external service %s", msg) - - if service.url: - tries = 10 if service.managed else 1 - for i in range(tries): - try: - await Server.from_orm(service.orm.server).wait_up( - http=True, timeout=1, ssl_context=ssl_context - ) - except AnyTimeoutError: - if service.managed: - status = await service.spawner.poll() - if status is not None: - self.log.error( - "Service %s exited with status %s", - service_name, - status, - ) - break - else: - break - else: - self.log.error( - "Cannot connect to %s service %s at %s. Is it running?", - service.kind, - service_name, - service.url, - ) - async def start(self): """Start the whole thing""" self.io_loop = loop = IOLoop.current() @@ -3365,12 +3324,21 @@ class JupyterHub(Application): # start the service(s) for service_name, service in self._service_map.items(): - service_status = await self._start_service( + service_status = await self.start_service( service_name, service, ssl_context ) - # if not service_status: - # # Stop the application if a service failed to start. - # self.exit(1) + if not service_status: + if service.from_config: + # Stop the application if a config-based service failed to start. + self.exit(1) + else: + # Only warn for database-based service, so that admin can connect + # to hub to remove the service. + self.log.error( + "Failed to start database-based service %s", + service_name, + exc_info=True, + ) await self.proxy.check_routes(self.users, self._service_map) diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index a469a4d4..563f847e 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -396,23 +396,23 @@ class Service(Base): url = Column(Unicode(2047), nullable=True) - oauth_client_allowed_scopes = Column( - JSONList, nullable=True, default=[] - ) # List of string + oauth_client_allowed_scopes = Column(JSONList, nullable=True) - info = Column(JSONDict, nullable=True, default={}) # Dict + info = Column(JSONDict, nullable=True) - display = Column(Boolean, default=True, nullable=True) + display = Column(Boolean, nullable=True) - oauth_no_confirm = Column(Boolean, default=False, nullable=True) + oauth_no_confirm = Column(Boolean, nullable=True) - command = Column(JSONList, nullable=True, default=[]) # List of string + command = Column(JSONList, nullable=True) - cwd = Column(Unicode, nullable=True) + cwd = Column(Unicode(4095), nullable=True) - environment = Column(JSONDict, nullable=True, default={}) # Dict + environment = Column(JSONDict, nullable=True) - user = Column(Unicode, nullable=True) + user = Column(Unicode(255), nullable=True) + + from_config = Column(Boolean, default=True) api_tokens = relationship( "APIToken", back_populates="service", cascade="all, delete-orphan" @@ -437,8 +437,6 @@ class Service(Base): ), ) - from_config = Column(Boolean, default=True, nullable=True) - oauth_client = relationship( 'OAuthClient', back_populates="service", @@ -460,6 +458,43 @@ class Service(Base): """ return db.query(cls).filter(cls.name == name).first() + def update_column(self, column_name: str, value: any) -> bool: + """_summary_ + + Args: + column_name (str): _description_ + value (any): _description_ + + Returns: + bool: _description_ + """ + if ( + hasattr(self, column_name) + and not getattr(Service, column_name).foreign_keys + ): + setattr(self, column_name, value) + return True + else: + return False + + def get_column(self, column_name: str): + """_summary_ + + Args: + column_name (str): _description_ + value (any): _description_ + + Returns: + bool: _description_ + """ + if ( + hasattr(self, column_name) + and not getattr(Service, column_name).foreign_keys + ): + return getattr(self, column_name) + else: + return None + class Expiring: """Mixin for expiring entries