Skip to content
4 changes: 4 additions & 0 deletions deployments/charts/service/templates/api-service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ spec:
- --service_hostname
- {{ .Values.services.service.hostname }}
{{- end }}
{{- if .Values.services.service.clientInstallUrl }}
- --client_install_url
- {{ .Values.services.service.clientInstallUrl }}
{{- end }}
{{- if .Values.global.osmoImageLocation }}
- --osmo_image_location
- {{ .Values.global.osmoImageLocation }}
Expand Down
4 changes: 4 additions & 0 deletions deployments/charts/service/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@ services:
# hostnames:
# - "example.local"

## URL for the client install script shown in version update messages
##
clientInstallUrl: "https://raw.githubusercontent.com/NVIDIA/OSMO/refs/heads/main/install.sh"

## Disable task metrics collection (set to true to disable)
##
disableTaskMetrics: false
Expand Down
1,061 changes: 1,061 additions & 0 deletions projects/PROJ-148-auth-rework/PROJ-148-oauth2-proxy-sidecar.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ class ConfigApiMapping(TypedDict):
'default': {'method': client.RequestMethod.PUT, 'payload_key': 'configs'},
'named': {'method': client.RequestMethod.PUT, 'payload_key': 'configs'},
},
config_history.ConfigHistoryType.GROUP_TEMPLATE: {
'default': {'method': client.RequestMethod.PUT, 'payload_key': 'configs'},
'named': {'method': client.RequestMethod.PUT, 'payload_key': 'configs'},
},
config_history.ConfigHistoryType.RESOURCE_VALIDATION: {
'default': {'method': client.RequestMethod.PUT, 'payload_key': 'configs_dict'},
'named': {'method': client.RequestMethod.PUT, 'payload_key': 'configs'},
Expand All @@ -84,6 +88,7 @@ class ConfigApiMapping(TypedDict):
config_history.ConfigHistoryType.DATASET,
config_history.ConfigHistoryType.POOL,
config_history.ConfigHistoryType.POD_TEMPLATE,
config_history.ConfigHistoryType.GROUP_TEMPLATE,
config_history.ConfigHistoryType.RESOURCE_VALIDATION,
config_history.ConfigHistoryType.BACKEND_TEST,
config_history.ConfigHistoryType.ROLE,
Expand Down
21 changes: 9 additions & 12 deletions src/lib/utils/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -16,6 +16,7 @@
SPDX-License-Identifier: Apache-2.0
"""

import base64
import enum
import json
import logging
Expand Down Expand Up @@ -59,14 +60,11 @@ class ResponseMode(enum.Enum):
STREAMING = 'STREAMING'


def handle_response(response, service_base_url: str, mode: ResponseMode = ResponseMode.JSON):
if response.headers.get(version.SERVICE_VERSION_HEADER) is not None:
client_version = version.VERSION
print(f'WARNING: New client {response.headers.get(version.SERVICE_VERSION_HEADER)} '
f'available.\nCurrent version: {client_version}.\n'
'Please update by running:\n'
f'curl -fsSL {service_base_url}/client/install.sh | bash',
file=sys.stderr)
def handle_response(response, mode: ResponseMode = ResponseMode.JSON):
if response.headers.get(version.VERSION_WARNING_HEADER) is not None:
warning = base64.b64decode(
response.headers.get(version.VERSION_WARNING_HEADER)).decode()
print(warning, file=sys.stderr)
if response.status_code != 200:
logging.error('Server responded with status code %s', response.status_code)

Expand Down Expand Up @@ -165,8 +163,7 @@ def device_code_login(self, url: str, device_endpoint: str, client_id: str | Non
'scope': 'openid offline_access profile'
}, timeout=login.TIMEOUT, headers={'User-Agent': self.user_agent})

parsed_url = urlparse(url)
result = handle_response(response, parsed_url.netloc)
result = handle_response(response)

device_code = result['device_code']
user_code = result['user_code']
Expand Down Expand Up @@ -392,7 +389,7 @@ def request(self, method: RequestMethod, endpoint: str,
case _ as unreachable:
assert_never(unreachable)

resp = handle_response(response, self._login_manager.url, mode)
resp = handle_response(response, mode)
return resp

async def create_websocket(
Expand Down
2 changes: 1 addition & 1 deletion src/lib/utils/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,7 @@ def strategic_merge_patch(original: Dict[str, Any], patch: Dict[str, Any]) -> Di
updated[key] = strategic_merge_patch(updated[key], value)
elif isinstance(value, list):
# Handle the case where the value is a list of dictionaries.
if all(isinstance(item, dict) for item in value):
if value and all(isinstance(item, dict) for item in value):
updated_list = []
for i, item in enumerate(updated[key]):
for patch_item in value:
Expand Down
1 change: 1 addition & 0 deletions src/lib/utils/config_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class ConfigHistoryType(enum.Enum):
BACKEND = 'BACKEND'
POOL = 'POOL'
POD_TEMPLATE = 'POD_TEMPLATE'
GROUP_TEMPLATE = 'GROUP_TEMPLATE'
RESOURCE_VALIDATION = 'RESOURCE_VALIDATION'
BACKEND_TEST = 'BACKEND_TEST'
ROLE = 'ROLE'
Expand Down
3 changes: 2 additions & 1 deletion src/lib/utils/version.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -27,6 +27,7 @@

VERSION_HEADER = 'x-osmo-client-version'
SERVICE_VERSION_HEADER = 'x-osmo-service-version'
VERSION_WARNING_HEADER = 'x-osmo-version-warning'


class Version(pydantic.BaseModel):
Expand Down
90 changes: 90 additions & 0 deletions src/service/core/config/config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
class ConfigNameType(enum.Enum):
""" Represents the config type for checking name. """
POD_TEMPLATE = 'Pod template'
GROUP_TEMPLATE = 'Group template'
POOL = 'Pool'
RESOURCE_VALIDATON = 'Resource validation'
PLATFORM = 'Platform'
Expand Down Expand Up @@ -700,6 +701,76 @@ def delete_pod_template(
)


@router.get('/api/configs/group_template', response_class=common.PrettyJSONResponse)
def list_group_templates() -> Dict[str, Dict]:
""" List all Group Template configurations """
postgres = connectors.PostgresConnector.get_instance()
return connectors.GroupTemplate.list_from_db(postgres)


@router.get('/api/configs/group_template/{name}', response_class=common.PrettyJSONResponse)
def read_group_template(name: str) -> Dict:
""" Read Group Template configurations """
postgres = connectors.PostgresConnector.get_instance()
return connectors.GroupTemplate.fetch_from_db(postgres, name)


@router.put('/api/configs/group_template')
def put_group_templates(request: objects.PutGroupTemplatesRequest,
username: str = fastapi.Depends(connectors.parse_username)):
""" Set Dict of Group Templates configurations """
for name in request.configs.keys():
_check_config_name(name, ConfigNameType.GROUP_TEMPLATE)

postgres = connectors.PostgresConnector.get_instance()
for name, group_template_dict in request.configs.items():
group_template = connectors.GroupTemplate(group_template=group_template_dict)
group_template.insert_into_db(postgres, name)

helpers.create_group_template_config_history_entry(
'',
username,
request.description or 'Put complete group template',
tags=request.tags,
)


@router.put('/api/configs/group_template/{name}')
def put_group_template(name: str,
request: objects.PutGroupTemplateRequest,
username: str = fastapi.Depends(connectors.parse_username)):
""" Put Group Template configurations """
_check_config_name(name, ConfigNameType.GROUP_TEMPLATE)
postgres = connectors.PostgresConnector.get_instance()
group_template = connectors.GroupTemplate(group_template=request.configs)
group_template.insert_into_db(postgres, name)

helpers.create_group_template_config_history_entry(
name,
username,
request.description or f'Put complete group template {name}',
tags=request.tags,
)


@router.delete('/api/configs/group_template/{name}')
def delete_group_template(
name: str,
request: objects.ConfigsRequest,
username: str = fastapi.Depends(connectors.parse_username),
):
""" Delete Group Template configurations """
postgres = connectors.PostgresConnector.get_instance()
connectors.GroupTemplate.delete_from_db(postgres, name)

helpers.create_group_template_config_history_entry(
name,
username,
request.description or f'Delete group template {name}',
tags=request.tags,
)


@router.get('/api/configs/resource_validation', response_class=common.PrettyJSONResponse)
def list_resource_validations() -> Dict[str, List[connectors.ResourceAssertion]]:
""" List all Resource Validation configurations """
Expand Down Expand Up @@ -1104,6 +1175,25 @@ def rollback_config(
),
username
)
elif request.config_type == connectors.ConfigHistoryType.GROUP_TEMPLATE:
# Delete all existing group templates
existing_group_templates = connectors.GroupTemplate.list_from_db(postgres)
group_templates_to_remove = [
group_template for group_template in existing_group_templates
if group_template not in history_entry['data'].keys()
]
for group_template in group_templates_to_remove:
connectors.GroupTemplate.delete_from_db(postgres, group_template)

# Replace with group template configs from history
put_group_templates(
objects.PutGroupTemplatesRequest(
configs=history_entry['data'],
description=description,
tags=request.tags
),
username
)
elif request.config_type == connectors.ConfigHistoryType.RESOURCE_VALIDATION:
# Delete all existing resource validations
existing_resource_validations = connectors.ResourceValidation.list_from_db(postgres)
Expand Down
21 changes: 21 additions & 0 deletions src/service/core/config/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,27 @@ def create_pod_template_config_history_entry(
)


def create_group_template_config_history_entry(
name: str,
username: str,
description: str,
tags: List[str] | None,
):
"""
Add a history entry for a group template config.
"""
postgres = connectors.PostgresConnector.get_instance()
group_templates = connectors.GroupTemplate.list_from_db(postgres)
postgres.create_config_history_entry(
config_type=connectors.ConfigHistoryType.GROUP_TEMPLATE,
name=name,
username=username,
data=group_templates,
description=description,
tags=tags,
)


def create_resource_validation_config_history_entry(
name: str,
username: str,
Expand Down
12 changes: 12 additions & 0 deletions src/service/core/config/objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,18 @@ class PutPodTemplateRequest(ConfigsRequest):
configs: Dict


class PutGroupTemplatesRequest(ConfigsRequest):
"""Request body for updating group templates with history tracking metadata."""

configs: Dict[str, Dict[str, Any]]


class PutGroupTemplateRequest(ConfigsRequest):
"""Request body for updating a group template with history tracking metadata."""

configs: Dict[str, Any]


class PutResourceValidationsRequest(ConfigsRequest):
"""Request body for updating resource validations with history tracking metadata."""

Expand Down
33 changes: 30 additions & 3 deletions src/service/core/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
SPDX-License-Identifier: Apache-2.0
"""

import base64
import datetime
import logging
from pathlib import Path
Expand Down Expand Up @@ -63,10 +64,15 @@ async def check_client_version(request: fastapi.Request, call_next):
return await call_next(request)
suggest_version_update = False
postgres = objects.WorkflowServiceContext.get().database
service_url = postgres.get_workflow_service_url()
cli_info = postgres.get_service_configs().cli_config
newest_client_version = version.Version.from_string(cli_info.latest_version) \
if cli_info.latest_version else version.VERSION
if cli_info.client_install_url:
install_command = f'Please run the following command:\n' \
f'curl -fsSL {cli_info.client_install_url} | bash'
else:
install_command = \
'Please update by running the install command in the documentation.'
if client_version < newest_client_version:
# If no min_supported_version specified, we allow all client versions
if cli_info.min_supported_version and\
Expand All @@ -75,14 +81,19 @@ async def check_client_version(request: fastapi.Request, call_next):
status_code=400,
content={'message': 'Your client is out of date. Client version is ' + \
f'{client_version_str} but the newest client version is '
f'{newest_client_version}. Please run the following command:\n'
f'curl -fsSL {service_url}/client/install.sh | bash',
f'{newest_client_version}.\n{install_command}',
'error_code': osmo_errors.OSMOError.error_code},
)
suggest_version_update = True
response = await call_next(request)
if suggest_version_update:
response.headers[version.SERVICE_VERSION_HEADER] = str(newest_client_version)
warning_msg = (
f'WARNING: New client {newest_client_version} available.\n'
f'Current version: {client_version_str}.\n'
f'{install_command}')
response.headers[version.VERSION_WARNING_HEADER] = (
base64.b64encode(warning_msg.encode()).decode())
return response


Expand Down Expand Up @@ -281,6 +292,21 @@ def set_default_service_url(postgres: connectors.PostgresConnector):
postgres.config.service_hostname)


def set_client_install_url(postgres: connectors.PostgresConnector,
config: objects.WorkflowServiceConfig):
curr_service_configs = postgres.get_service_configs()
if curr_service_configs.cli_config.client_install_url != config.client_install_url:
updated_cli_config = curr_service_configs.cli_config.dict()
updated_cli_config['client_install_url'] = config.client_install_url
config_service.patch_service_configs(
request=config_objects.PatchConfigRequest(
configs_dict={'cli_config': updated_cli_config}
),
username='System',
)
logging.info('Updated client_install_url to: %s', config.client_install_url)


def setup_default_admin(postgres: connectors.PostgresConnector,
config: objects.WorkflowServiceConfig):
"""
Expand Down Expand Up @@ -392,6 +418,7 @@ def configure_app(target_app: fastapi.FastAPI, config: objects.WorkflowServiceCo
create_default_pool(postgres)
set_default_backend_images(postgres)
set_default_service_url(postgres)
set_client_install_url(postgres, config)
setup_default_admin(postgres, config)

# Instantiate QueryParser
Expand Down
2 changes: 1 addition & 1 deletion src/service/core/tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def test_outdated_client_receives_update_prompt_if_min_supported_version_is_set(

# Assert
self.assertEqual(response.status_code, 400)
self.assertIn('client/install.sh', str(response.content))
self.assertIn('Please update', str(response.content))

def test_get_users_from_all_workflows(self):
# Arrange
Expand Down
4 changes: 4 additions & 0 deletions src/service/core/workflow/objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ class WorkflowServiceConfig(connectors.RedisConfig, connectors.PostgresConfig,
command_line='logout_endpoint',
default=None,
description='The url to bind to when authenticating with the logout endpoint.')
client_install_url: str | None = pydantic.Field(
command_line='client_install_url',
default=None,
description='The URL for the client install script shown in version update messages.')
progress_file: str = pydantic.Field(
command_line='progress_file',
env='OSMO_PROGRESS_FILE',
Expand Down
Loading
Loading