Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide an API endpoint to get current effective traitlets configuration #4406

Open
yuvipanda opened this issue Apr 4, 2023 · 8 comments · May be fixed by #4442
Open

Provide an API endpoint to get current effective traitlets configuration #4406

yuvipanda opened this issue Apr 4, 2023 · 8 comments · May be fixed by #4442

Comments

@yuvipanda
Copy link
Contributor

When extending JupyterHub via services, it is quite difficult to figure out what exactly is the effective configuration of the hub. For example, there isn't a way to know what image is going to be launched, based on traitlets configuration! It would be extremely helpful to have an API endpoint that just dumps out traitlets values. This would drastically increase the ability of things like the jupyterhub configurator which currently can't tell what the default image is, or the resource limits, etc.

However, dumping all traitlets info is probably a bad idea, because it can contain sensitive info. So I propose we have an allow-list of traitlet values that will be exposed readonly via this config API. So it would be something like:

c.JupyterHub.config_api_allowed_traitlets = [
  "KubeSpawner.image",
  "KubeSpawner.profile_list",
  "Spawner.memory_limit"
]

And then if you hit a /api/config endpoint, you'll get back the current calculated values of all these allowed traitlets.

@minrk
Copy link
Member

minrk commented Apr 4, 2023

Seems reasonable. Depending on how it's implemented, this probably won't get unconfigured default values, which come from classes and not the config object. Do you expect that to be an issue?

@yuvipanda
Copy link
Contributor Author

Is there a way to get those too? Ideally I'd love to say this is the current effective configuration according to traitlets, and that should include the defaults right? Rather than just the overrides.

@minrk
Copy link
Member

minrk commented Apr 4, 2023

It's definitely possible. Unfortunately, it's a bit tricky for Spawners in particular, because the 'right' way to get the current resolved configuration requires instantiating a configurable object, so you can let Configurable handle resolving dynamic defaults. But what Spawner do you instantiate to get that? It's not a problem for JupyterHub or Authenticator, where there's an instance already present.

It's rather straightforward, given an instantiated configurable obj:

clsname = obj.__class__.__name__

for name in obj.traits(config=True):
    value = getattr(obj, name)
    print(f"{clsname}.{name} = {value!r}")

For simple defaults, you can use cls.class_traits(config=True) and get trait.default_value without instantiating, but this will often be traitlets.Undefined if the default is calculated dynamically based on other values, as many are. It would also be up to you to resolve whether the value is configured or not:

from kubespawner import KubeSpawner

cls = KubeSpawner

for name, t in cls.class_traits(config=True).items():
    print(f"{cls.__name__}.{name} = {t.default_value!r}")

gives

output
KubeSpawner.after_pod_created_hook = None
KubeSpawner.allow_privilege_escalation = False
KubeSpawner.args = traitlets.Undefined
KubeSpawner.auth_state_hook = None
KubeSpawner.automount_service_account_token = None
KubeSpawner.cmd = None
KubeSpawner.common_labels = traitlets.Undefined
KubeSpawner.component_label = 'singleuser-server'
KubeSpawner.consecutive_failure_limit = 0
KubeSpawner.container_security_context = traitlets.Undefined
KubeSpawner.cpu_guarantee = None
KubeSpawner.cpu_limit = None
KubeSpawner.debug = False
KubeSpawner.default_url = ''
KubeSpawner.delete_grace_period = 1
KubeSpawner.delete_pvc = True
KubeSpawner.delete_stopped_pods = True
KubeSpawner.disable_user_config = False
KubeSpawner.dns_name_template = '{name}.{namespace}.svc.cluster.local'
KubeSpawner.enable_user_namespaces = False
KubeSpawner.env_keep = traitlets.Undefined
KubeSpawner.environment = traitlets.Undefined
KubeSpawner.events_enabled = True
KubeSpawner.extra_annotations = traitlets.Undefined
KubeSpawner.extra_container_config = traitlets.Undefined
KubeSpawner.extra_containers = traitlets.Undefined
KubeSpawner.extra_labels = traitlets.Undefined
KubeSpawner.extra_pod_config = traitlets.Undefined
KubeSpawner.extra_resource_guarantees = traitlets.Undefined
KubeSpawner.extra_resource_limits = traitlets.Undefined
KubeSpawner.fs_gid = None
KubeSpawner.get_pod_url = None
KubeSpawner.gid = None
KubeSpawner.http_timeout = 30
KubeSpawner.hub_connect_ip = ''
KubeSpawner.hub_connect_port = 0
KubeSpawner.hub_connect_url = None
KubeSpawner.image = 'jupyterhub/singleuser:latest'
KubeSpawner.image_pull_policy = 'IfNotPresent'
KubeSpawner.image_pull_secrets = traitlets.Undefined
KubeSpawner.init_containers = traitlets.Undefined
KubeSpawner.ip = '0.0.0.0'
KubeSpawner.k8s_api_host = ''
KubeSpawner.k8s_api_request_retry_timeout = 30
KubeSpawner.k8s_api_request_timeout = 3
KubeSpawner.k8s_api_ssl_ca_cert = ''
KubeSpawner.k8s_api_threadpool_workers = 0
KubeSpawner.lifecycle_hooks = traitlets.Undefined
KubeSpawner.mem_guarantee = None
KubeSpawner.mem_limit = None
KubeSpawner.modify_pod_hook = None
KubeSpawner.namespace = ''
KubeSpawner.node_affinity_preferred = traitlets.Undefined
KubeSpawner.node_affinity_required = traitlets.Undefined
KubeSpawner.node_selector = traitlets.Undefined
KubeSpawner.notebook_dir = ''
KubeSpawner.oauth_client_allowed_scopes = traitlets.Undefined
KubeSpawner.oauth_roles = traitlets.Undefined
KubeSpawner.options_form = traitlets.Undefined
KubeSpawner.options_from_form = traitlets.Undefined
KubeSpawner.pod_affinity_preferred = traitlets.Undefined
KubeSpawner.pod_affinity_required = traitlets.Undefined
KubeSpawner.pod_anti_affinity_preferred = traitlets.Undefined
KubeSpawner.pod_anti_affinity_required = traitlets.Undefined
KubeSpawner.pod_connect_ip = ''
KubeSpawner.pod_name_template = 'jupyter-{username}--{servername}'
KubeSpawner.pod_security_context = traitlets.Undefined
KubeSpawner.poll_interval = 30
KubeSpawner.port = 0
KubeSpawner.post_stop_hook = None
KubeSpawner.pre_spawn_hook = None
KubeSpawner.priority_class_name = ''
KubeSpawner.privileged = False
KubeSpawner.profile_form_template = '\n        <style>\n            /*\n                .profile divs holds two div tags: one for a radio button, and one\n                for the profile\'s content.\n            */\n            #kubespawner-profiles-list .profile {\n                display: flex;\n                flex-direction: row;\n                font-weight: normal;\n                border-bottom: 1px solid #ccc;\n                padding-bottom: 12px;\n            }\n\n            #kubespawner-profiles-list .profile .radio {\n                padding: 12px;\n            }\n\n            /* .option divs holds a label and a select tag */\n            #kubespawner-profiles-list .profile .option {\n                display: flex;\n                flex-direction: row;\n                align-items: center;\n                padding-bottom: 12px;\n            }\n\n            #kubespawner-profiles-list .profile .option label {\n                font-weight: normal;\n                margin-right: 8px;\n                min-width: 96px;\n            }\n        </style>\n\n        <div class=\'form-group\' id=\'kubespawner-profiles-list\'>\n            {%- for profile in profile_list %}\n            {#- Wrap everything in a <label> so clicking anywhere selects the option #}\n            <label for=\'profile-item-{{ profile.slug }}\' class=\'profile\'>\n                <div class=\'radio\'>\n                    <input type=\'radio\' name=\'profile\' id=\'profile-item-{{ profile.slug }}\' value=\'{{ profile.slug }}\' {% if profile.default %}checked{% endif %} />\n                </div>\n                <div>\n                    <h3>{{ profile.display_name }}</h3>\n\n                    {%- if profile.description %}\n                    <p>{{ profile.description }}</p>\n                    {%- endif %}\n\n                    {%- if profile.profile_options %}\n                    <div>\n                        {%- for k, option in profile.profile_options.items() %}\n                        <div class=\'option\'>\n                            <label for=\'profile-option-{{profile.slug}}-{{k}}\'>{{option.display_name}}</label>\n                            <select name="profile-option-{{profile.slug}}-{{k}}" class="form-control">\n                                {%- for k, choice in option[\'choices\'].items() %}\n                                <option value="{{ k }}" {% if choice.default %}selected{%endif %}>{{ choice.display_name }}</option>\n                                {%- endfor %}\n                            </select>\n                        </div>\n                        {%- endfor %}\n                    </div>\n                    {%- endif %}\n                </div>\n            </label>\n            {%- endfor %}\n        </div>\n        '
KubeSpawner.profile_list = traitlets.Undefined
KubeSpawner.pvc_name_template = 'claim-{username}--{servername}'
KubeSpawner.scheduler_name = None
KubeSpawner.secret_mount_path = '/etc/jupyterhub/ssl/'
KubeSpawner.secret_name_template = 'jupyter-{username}{servername}'
KubeSpawner.server_token_scopes = traitlets.Undefined
KubeSpawner.service_account = None
KubeSpawner.services_enabled = False
KubeSpawner.ssl_alt_names = traitlets.Undefined
KubeSpawner.ssl_alt_names_include_local = True
KubeSpawner.start_timeout = 60
KubeSpawner.storage_access_modes = traitlets.Undefined
KubeSpawner.storage_capacity = None
KubeSpawner.storage_class = None
KubeSpawner.storage_extra_annotations = traitlets.Undefined
KubeSpawner.storage_extra_labels = traitlets.Undefined
KubeSpawner.storage_pvc_ensure = False
KubeSpawner.storage_selector = traitlets.Undefined
KubeSpawner.supplemental_gids = traitlets.Undefined
KubeSpawner.tolerations = traitlets.Undefined
KubeSpawner.uid = None
KubeSpawner.user_namespace_annotations = traitlets.Undefined
KubeSpawner.user_namespace_labels = traitlets.Undefined
KubeSpawner.user_namespace_template = '{hubnamespace}-{username}'
KubeSpawner.volume_mounts = traitlets.Undefined
KubeSpawner.volumes = traitlets.Undefined
KubeSpawner.working_dir = None

If you don't instantiate the object, you'd also have to reimplement resolving which values are configured vs the default value. Not the most complex, but definitely nontrivial to handle everything from inheritance (Spawner and KubeSpawner both affect KubeSpawner) to LazyConfigValues c.Spawner.environment.update({"x": "y"}).

The simplest way would definitely be to instantiate a fake Spawner with a mock User not hooked up to the db. You might hit edge cases in exactly how 'realistic' the mock is.

The last challenge would be to locate additional configurable classes (e.g. if the Spawner delegates some of its config to sub-objects, as kubespawner does in kubespawner.reflector.ResourceReflector). How is JupyterHub supposed to discover that ResourceReflector.request_timeout maps to kubespawner.reflector.ResourceReflector to find its defaults? The simple way (and they way handled by --help-all and --generate-config is to have a classes list that you maintain. Requiring additional classes be registered explicitly in this way wouldn't be the worst thing in the world, since it would be a small, slowly changing list.

@yuvipanda
Copy link
Contributor Author

I would say we can open by looking for traitlets' default_value, and being okay with traitlets.Undefined showing up when that is dynamic. It would already be a big positive change over what we have now. And I think that would mostly work for the use cases I have in mind, which are about finding things that the admin has configured. We can revisit if this runs into problems?

@minrk
Copy link
Member

minrk commented Apr 4, 2023

Yeah, I think that's okay.

FWIW, here's instantiating a Spawner with a mock user (could be more robust if it were built-in):

from dataclasses import dataclass, field

from traitlets.config import Config
from jupyterhub.user import User
from kubespawner import KubeSpawner

@dataclass
class MockORMUser:
    id: int = 0
    name: str = "username"
    admin: bool = False
    encrypted_auth_state: bytes | None = None
    state: dict = field(default_factory=dict)
    
    
    # relationships likely to be used
    orm_spawners: dict = field(default_factory=dict)
    groups: list = field(default_factory=list)
    
    # other fields technically exist, but shouldn't be accessed by Spawners
    # these likely won't be exposed when we separate Spawners from db access
    created = None
    last_activity = None
    cookie_id: str = ""
    roles: list = field(default_factory=list)
    
    


# mock out _new_orm_spawner to avoid trying to perform a db operation
class MockUser(User):
    def _new_orm_spawner(self, servername=""):
        pass

class MockDB:
    def add(self, obj):
        pass

    def commit(self):
        pass

user = MockUser(orm_user=MockORMUser(), db=MockDB())

config = Config()
config.Spawner.cmd = "custom-command"
spawner = KubeSpawner(config=config, user=user)

obj = spawner
clsname = obj.__class__.__name__

for name in obj.traits(config=True):
    value = getattr(obj, name)
    print(f"{clsname}.{name} = {value!r}")
which gives
KubeSpawner.after_pod_created_hook = None
KubeSpawner.allow_privilege_escalation = False
KubeSpawner.args = []
KubeSpawner.auth_state_hook = None
KubeSpawner.automount_service_account_token = None
KubeSpawner.cmd = ['custom command']
KubeSpawner.common_labels = {'app': 'jupyterhub', 'heritage': 'jupyterhub'}
KubeSpawner.component_label = 'singleuser-server'
KubeSpawner.consecutive_failure_limit = 0
KubeSpawner.container_security_context = {}
KubeSpawner.cpu_guarantee = None
KubeSpawner.cpu_limit = None
KubeSpawner.debug = False
KubeSpawner.default_url = ''
KubeSpawner.delete_grace_period = 1
KubeSpawner.delete_pvc = True
KubeSpawner.delete_stopped_pods = True
KubeSpawner.disable_user_config = False
KubeSpawner.dns_name_template = '{name}.{namespace}.svc.cluster.local'
KubeSpawner.enable_user_namespaces = False
KubeSpawner.env_keep = []
KubeSpawner.environment = {}
KubeSpawner.events_enabled = True
KubeSpawner.extra_annotations = {}
KubeSpawner.extra_container_config = {}
KubeSpawner.extra_containers = []
KubeSpawner.extra_labels = {}
KubeSpawner.extra_pod_config = {}
KubeSpawner.extra_resource_guarantees = {}
KubeSpawner.extra_resource_limits = {}
KubeSpawner.fs_gid = None
KubeSpawner.get_pod_url = None
KubeSpawner.gid = None
KubeSpawner.http_timeout = 30
KubeSpawner.hub_connect_ip = ''
KubeSpawner.hub_connect_port = 0
KubeSpawner.hub_connect_url = None
KubeSpawner.image = 'jupyterhub/singleuser:latest'
KubeSpawner.image_pull_policy = 'IfNotPresent'
KubeSpawner.image_pull_secrets = []
KubeSpawner.init_containers = []
KubeSpawner.ip = '0.0.0.0'
KubeSpawner.k8s_api_host = ''
KubeSpawner.k8s_api_request_retry_timeout = 30
KubeSpawner.k8s_api_request_timeout = 3
KubeSpawner.k8s_api_ssl_ca_cert = ''
KubeSpawner.k8s_api_threadpool_workers = 0
KubeSpawner.lifecycle_hooks = {}
KubeSpawner.mem_guarantee = None
KubeSpawner.mem_limit = None
KubeSpawner.modify_pod_hook = None
KubeSpawner.namespace = 'default'
KubeSpawner.node_affinity_preferred = []
KubeSpawner.node_affinity_required = []
KubeSpawner.node_selector = {}
KubeSpawner.notebook_dir = ''
KubeSpawner.oauth_client_allowed_scopes = []
KubeSpawner.oauth_roles = []
KubeSpawner.options_form = ''
KubeSpawner.options_from_form = <bound method KubeSpawner._options_from_form of <kubespawner.spawner.KubeSpawner object at 0x15c9bce80>>
KubeSpawner.pod_affinity_preferred = []
KubeSpawner.pod_affinity_required = []
KubeSpawner.pod_anti_affinity_preferred = []
KubeSpawner.pod_anti_affinity_required = []
KubeSpawner.pod_connect_ip = ''
KubeSpawner.pod_name_template = 'jupyter-{username}--{servername}'
KubeSpawner.pod_security_context = {}
KubeSpawner.poll_interval = 30
KubeSpawner.port = 8888
KubeSpawner.post_stop_hook = None
KubeSpawner.pre_spawn_hook = None
KubeSpawner.priority_class_name = ''
KubeSpawner.privileged = False
KubeSpawner.profile_form_template = '\n        <style>\n            /*\n                .profile divs holds two div tags: one for a radio button, and one\n                for the profile\'s content.\n            */\n            #kubespawner-profiles-list .profile {\n                display: flex;\n                flex-direction: row;\n                font-weight: normal;\n                border-bottom: 1px solid #ccc;\n                padding-bottom: 12px;\n            }\n\n            #kubespawner-profiles-list .profile .radio {\n                padding: 12px;\n            }\n\n            /* .option divs holds a label and a select tag */\n            #kubespawner-profiles-list .profile .option {\n                display: flex;\n                flex-direction: row;\n                align-items: center;\n                padding-bottom: 12px;\n            }\n\n            #kubespawner-profiles-list .profile .option label {\n                font-weight: normal;\n                margin-right: 8px;\n                min-width: 96px;\n            }\n        </style>\n\n        <div class=\'form-group\' id=\'kubespawner-profiles-list\'>\n            {%- for profile in profile_list %}\n            {#- Wrap everything in a <label> so clicking anywhere selects the option #}\n            <label for=\'profile-item-{{ profile.slug }}\' class=\'profile\'>\n                <div class=\'radio\'>\n                    <input type=\'radio\' name=\'profile\' id=\'profile-item-{{ profile.slug }}\' value=\'{{ profile.slug }}\' {% if profile.default %}checked{% endif %} />\n                </div>\n                <div>\n                    <h3>{{ profile.display_name }}</h3>\n\n                    {%- if profile.description %}\n                    <p>{{ profile.description }}</p>\n                    {%- endif %}\n\n                    {%- if profile.profile_options %}\n                    <div>\n                        {%- for k, option in profile.profile_options.items() %}\n                        <div class=\'option\'>\n                            <label for=\'profile-option-{{profile.slug}}-{{k}}\'>{{option.display_name}}</label>\n                            <select name="profile-option-{{profile.slug}}-{{k}}" class="form-control">\n                                {%- for k, choice in option[\'choices\'].items() %}\n                                <option value="{{ k }}" {% if choice.default %}selected{%endif %}>{{ choice.display_name }}</option>\n                                {%- endfor %}\n                            </select>\n                        </div>\n                        {%- endfor %}\n                    </div>\n                    {%- endif %}\n                </div>\n            </label>\n            {%- endfor %}\n        </div>\n        '
KubeSpawner.profile_list = []
KubeSpawner.pvc_name_template = 'claim-{username}--{servername}'
KubeSpawner.scheduler_name = None
KubeSpawner.secret_mount_path = '/etc/jupyterhub/ssl/'
KubeSpawner.secret_name_template = 'jupyter-{username}{servername}'
KubeSpawner.server_token_scopes = []
KubeSpawner.service_account = None
KubeSpawner.services_enabled = False
KubeSpawner.ssl_alt_names = ['DNS:jupyter-username.default.svc.cluster.local', 'DNS:jupyter-username', 'DNS:jupyter-username.default', 'DNS:jupyter-username.default.svc']
KubeSpawner.ssl_alt_names_include_local = False
KubeSpawner.start_timeout = 60
KubeSpawner.storage_access_modes = ['ReadWriteOnce']
KubeSpawner.storage_capacity = None
KubeSpawner.storage_class = None
KubeSpawner.storage_extra_annotations = {}
KubeSpawner.storage_extra_labels = {}
KubeSpawner.storage_pvc_ensure = False
KubeSpawner.storage_selector = {}
KubeSpawner.supplemental_gids = []
KubeSpawner.tolerations = []
KubeSpawner.uid = None
KubeSpawner.user_namespace_annotations = {}
KubeSpawner.user_namespace_labels = {}
KubeSpawner.user_namespace_template = '{hubnamespace}-{username}'
KubeSpawner.volume_mounts = []
KubeSpawner.volumes = []
KubeSpawner.working_dir = None

@yuvipanda
Copy link
Contributor Author

@minrk think i can bribe you to do this somehow? :D

@aktech
Copy link
Contributor

aktech commented Nov 3, 2023

This seems like a very useful feature. Is there any way to get the traitlets (however hacky) configuration in a service as of now?

I understand I can set environment variable for the service, but not everything I need can be easily passed via environment variable, imagine a complex python object or a function/callable, etc.

@minrk
Copy link
Member

minrk commented Nov 3, 2023

#4442 adds this and is awaiting review.

But complex objects like Python functions are always going to be hard to pass around. Depending on your deployment, one option is to pass the config files themselves and actually load the config files directly, e.g.

from jupyterhub.app import JupyterHub
hub = JupyterHub()
hub.load_config_file(hub.config_file) # or '/path/to/jupyterhub_config.py'
config = hub.config

To get the full effect of config, it's always best to access attributes of a configured object instead of directly accessing a config object (e.g. hub.attribute instead of config.JupyterHub.attribute).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants