diff --git a/projects/path-scoped-registry-credentials.md b/projects/path-scoped-registry-credentials.md
new file mode 100644
index 000000000..29b0ff360
--- /dev/null
+++ b/projects/path-scoped-registry-credentials.md
@@ -0,0 +1,116 @@
+
+
+# Path-Scoped Registry Credentials
+
+**Author**: [Trasha Dewan](https://github.com/tdewanNvidia)
+**PIC**: [Trasha Dewan](https://github.com/tdewanNvidia)
+**Proposal Issue**: [#1113](https://github.com/nvidia/osmo/issues/1113)
+
+## Overview
+
+OSMO should allow registry credentials to be scoped to a registry path, not only
+a registry host. This lets workflows select the correct credential when multiple
+repositories share the same registry host.
+
+### Motivation
+
+Docker and Kubernetes image pull configuration supports matching credentials by
+registry path. OSMO should preserve that behavior so users can safely configure
+separate credentials for separate repository namespaces under one registry host.
+
+### Problem
+
+OSMO previously normalized registry credentials to host-only keys. That made
+path-specific credentials indistinguishable and could cause workflow validation
+or generated image pull secrets to use the wrong credential for an image path.
+
+## Use Cases
+
+| Use Case | Description |
+|---|---|
+| Separate credentials by path | A user configures different credentials for different repository paths under the same registry host. |
+| Pull workflow images | OSMO validates and pulls an image using only credentials whose scope matches that image path. |
+| Preserve host-level fallback | A host-level credential still applies when no more specific path-scoped credential exists. |
+
+## Requirements
+
+| Title | Description | Type |
+|---|---|---|
+| Preserve registry path | OSMO shall store the full registry path supplied for a registry credential. | Functional |
+| Path-aware matching | OSMO shall match image references to credentials by registry host and path segment. | Functional |
+| Specificity ordering | OSMO shall try more specific matching scopes before less specific scopes. | Functional |
+| Pull secret generation | OSMO shall include matching registry credentials in generated image pull secrets. | Functional |
+| Backwards compatibility | Existing host-scoped registry credentials shall continue to work. | Functional |
+
+## Architectural Details
+
+The change adds shared registry-scope helpers and routes all registry credential
+lookup paths through them. A registry scope is the registry host plus an optional
+repository path. Matching is segment-aware, so a scope for one sibling path does
+not match another sibling path.
+
+Key components:
+
+| Component | Purpose |
+|---|---|
+| `src/lib/utils/common.py` | Normalizes registry scopes and provides matching helpers. |
+| `src/utils/connectors/postgres.py` | Returns registry credentials keyed by normalized scope and decrypts only matching credentials. |
+| `src/utils/job/workflow.py` | Validates private workflow images against matching user credentials. |
+| `src/utils/job/task.py` | Builds image pull secrets from matching registry credentials. |
+| `src/service/core/workflow/objects.py` | Validates registry credential creation using the normalized scope. |
+
+## Detailed Design
+
+Registry profiles are normalized into a canonical scope:
+
+```text
+[/repository/path]
+```
+
+Default HTTPS port notation is canonicalized so equivalent scopes compare the
+same way. Non-default ports remain part of the scope.
+
+When validating or preparing a workflow image, OSMO:
+
+1. Parses the image reference.
+2. Computes the image registry scope.
+3. Finds all stored credential scopes that contain the image scope.
+4. Orders matches from most specific to least specific.
+5. Uses only those matching credentials for validation and image pull secret
+ generation.
+
+## Backwards Compatibility
+
+Existing host-scoped credentials continue to match all image paths under the
+same registry host. Path-scoped credentials add a more specific option without
+changing the host-level behavior.
+
+## Security
+
+Credential matching is narrowed to the image path being used. The filtered
+lookup avoids decrypting unrelated registry credentials during image validation.
+
+## Testing
+
+- Unit tests cover scope normalization, matching, workflow validation, and pull secret generation.
+- Manual CLI validation confirms distinct path-scoped credentials are stored and matched separately.
+
+## Open Questions
+
+- None.
diff --git a/src/cli/credential.py b/src/cli/credential.py
index ba975b112..473c03619 100644
--- a/src/cli/credential.py
+++ b/src/cli/credential.py
@@ -198,7 +198,7 @@ def setup_parser(parser: argparse._SubParsersAction):
set_parser = subparsers.add_parser('set', help='Create or update a credential',
formatter_class=argparse.RawTextHelpFormatter,
epilog='Ex. osmo credential set registry_cred_name --type REGISTRY ' +
- '--payload registry=your_registry username=your_username auth=xxxxxx \n' +
+ '--payload registry=your_registry_or_path username=your_username auth=xxxxxx \n' +
'Ex. osmo credential set data_cred_name --type DATA ' +
'--payload access_key_id=your_s3_username access_key=xxxxxx ' +
'endpoint=s3://bucket \n' +
diff --git a/src/lib/utils/common.py b/src/lib/utils/common.py
index 60ea5ee46..cd5d18373 100644
--- a/src/lib/utils/common.py
+++ b/src/lib/utils/common.py
@@ -30,6 +30,7 @@
import threading
import time
from typing import Annotated, Any, Callable, Coroutine, Dict, Generator, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple
+from urllib.parse import urlparse
import uuid
import pydantic
@@ -449,6 +450,60 @@ def registry_parse(name: str) -> str:
return name
+def normalize_registry_scope(registry_scope: str) -> str:
+ """Normalize a registry credential scope while preserving path prefixes."""
+ scope = registry_scope.strip().rstrip('/')
+ if not scope:
+ return DEFAULT_REGISTRY
+
+ parsed = urlparse(scope if '://' in scope else f'//{scope}')
+ if parsed.netloc:
+ registry = registry_parse(parsed.netloc)
+ if registry.endswith(':443'):
+ registry = registry[:-4]
+ path = parsed.path.strip('/')
+ if path:
+ return f'{registry}/{path}'
+ return registry
+
+ return registry_parse(scope)
+
+
+def image_registry_scope(image_info: DockerImageInfo) -> str:
+ """Return the registry scope string Kubernetes matches credentials against."""
+ registry = image_info.host
+ if image_info.port != 443:
+ registry = f'{registry}:{image_info.port}'
+ return f'{registry}/{image_info.name}'
+
+
+def registry_scope_contains(parent_scope: str, child_scope: str) -> bool:
+ """Return whether child_scope is inside parent_scope by path segment."""
+ parent = normalize_registry_scope(parent_scope)
+ child = normalize_registry_scope(child_scope)
+ return child == parent or child.startswith(f'{parent}/')
+
+
+def registry_scope_matches_image(registry_scope: str, image_info: DockerImageInfo) -> bool:
+ """Return whether a registry credential scope applies to an image."""
+ return registry_scope_contains(registry_scope, image_registry_scope(image_info))
+
+
+def matching_registry_scopes(
+ image_info: DockerImageInfo,
+ registry_scopes: Iterable[str],
+) -> List[str]:
+ """Return all credential scopes that match an image, most specific first."""
+ matches = [
+ registry_scope for registry_scope in registry_scopes
+ if registry_scope_matches_image(registry_scope, image_info)
+ ]
+ return sorted(
+ matches,
+ key=lambda registry_scope: len(normalize_registry_scope(registry_scope)),
+ reverse=True)
+
+
def docker_parse(image: str) -> DockerImageInfo:
""" Parses a docker image into its separate components """
# Parse image according to rules here
diff --git a/src/lib/utils/tests/test_common.py b/src/lib/utils/tests/test_common.py
index d79b86401..fff1f0617 100644
--- a/src/lib/utils/tests/test_common.py
+++ b/src/lib/utils/tests/test_common.py
@@ -104,6 +104,49 @@ def test_docker_parse(self):
self.assertEqual(result.tag, exp_tag, f'tag mismatch for {image}')
self.assertEqual(result.digest, exp_digest, f'digest mismatch for {image}')
+ def test_normalize_registry_scope_preserves_path(self):
+ self.assertEqual(common.normalize_registry_scope('docker.io'), common.DEFAULT_REGISTRY)
+ self.assertEqual(
+ common.normalize_registry_scope('https://nvcr.io/nvstaging/osmo/'),
+ 'nvcr.io/nvstaging/osmo')
+ self.assertEqual(
+ common.normalize_registry_scope('localhost:5000/team/image'),
+ 'localhost:5000/team/image')
+ self.assertEqual(
+ common.normalize_registry_scope('registry.example.com:443/team/image'),
+ 'registry.example.com/team/image')
+
+ def test_registry_scope_matches_image_by_path_segment(self):
+ image_info = common.docker_parse('nvcr.io/nvstaging/osmo/app:latest')
+
+ self.assertTrue(common.registry_scope_matches_image('nvcr.io', image_info))
+ self.assertTrue(
+ common.registry_scope_matches_image('nvcr.io/nvstaging/osmo', image_info))
+ self.assertFalse(
+ common.registry_scope_matches_image('nvcr.io/nvstaging/isaac', image_info))
+ self.assertFalse(
+ common.registry_scope_matches_image('nvcr.io/nvstaging/osmosis', image_info))
+
+ def test_matching_registry_scopes_returns_most_specific_first(self):
+ image_info = common.docker_parse('nvcr.io/nvstaging/osmo/app:latest')
+
+ matches = common.matching_registry_scopes(image_info, [
+ 'nvcr.io',
+ 'nvcr.io/nvstaging/isaac',
+ 'nvcr.io/nvstaging/osmo',
+ ])
+
+ self.assertEqual(matches, ['nvcr.io/nvstaging/osmo', 'nvcr.io'])
+
+ def test_image_registry_scope_includes_non_default_port(self):
+ image_info = common.docker_parse('registry.example.com:5000/org/image:v1')
+
+ self.assertEqual(
+ common.image_registry_scope(image_info),
+ 'registry.example.com:5000/org/image')
+ self.assertTrue(
+ common.registry_scope_matches_image('registry.example.com:5000/org', image_info))
+
class TestPydanticEncoder(unittest.TestCase):
""" Tests for pydantic_encoder. """
diff --git a/src/service/core/workflow/objects.py b/src/service/core/workflow/objects.py
index bf6717357..690411f01 100644
--- a/src/service/core/workflow/objects.py
+++ b/src/service/core/workflow/objects.py
@@ -631,10 +631,14 @@ def to_db_row(self, user: str, postgres: connectors.PostgresConnector) -> Creden
connectors.PostgresConnector.encode_hstore(payload))
def valid_cred(self, workflow_config: connectors.WorkflowConfig):
- self.registry = common.registry_parse(self.registry)
- if self.registry in workflow_config.credential_config.disable_registry_validation:
+ self.registry = common.normalize_registry_scope(self.registry)
+ if any(
+ common.registry_scope_contains(disabled_registry, self.registry)
+ for disabled_registry in workflow_config.credential_config.disable_registry_validation
+ ):
return
- response = common.registry_auth(f'https://{self.registry}/v2/',
+ registry_host = self.registry.split('/', 1)[0]
+ response = common.registry_auth(f'https://{registry_host}/v2/',
self.username, self.auth)
if response.status_code != 200:
raise osmo_errors.OSMOCredentialError('Registry authentication failed.')
diff --git a/src/service/core/workflow/tests/test_helpers.py b/src/service/core/workflow/tests/test_helpers.py
index 2fcbfa68c..fae20356c 100644
--- a/src/service/core/workflow/tests/test_helpers.py
+++ b/src/service/core/workflow/tests/test_helpers.py
@@ -77,6 +77,26 @@ def make_workflow(name: str, groups: list) -> objects.WorkflowQueryResponse:
)
+class TestUserRegistryCredential(unittest.TestCase):
+ def test_valid_cred_preserves_path_scoped_registry(self):
+ credential = objects.UserRegistryCredential(
+ registry='nvcr.io/nvidia',
+ username='user',
+ auth='token',
+ )
+ workflow_config = mock.Mock()
+ workflow_config.credential_config.disable_registry_validation = []
+
+ with mock.patch(
+ 'src.service.core.workflow.objects.common.registry_auth',
+ return_value=mock.Mock(status_code=200),
+ ) as registry_auth:
+ credential.valid_cred(workflow_config)
+
+ self.assertEqual(credential.registry, 'nvcr.io/nvidia')
+ registry_auth.assert_called_once_with('https://nvcr.io/v2/', 'user', 'token')
+
+
class TestGetResourceNodeHash(unittest.TestCase):
def test_get_resource_node_hash_empty_returns_hash_of_empty_string(self):
diff --git a/src/utils/connectors/postgres.py b/src/utils/connectors/postgres.py
index cbe8ad970..8991106e6 100644
--- a/src/utils/connectors/postgres.py
+++ b/src/utils/connectors/postgres.py
@@ -1541,16 +1541,56 @@ def get_generic_cred(self, user: str, cred_name: str) -> Any:
def get_registry_cred(self, user: str, registry: str) -> Any:
""" Fetch docker credentials by registry name. """
+ registry = common.normalize_registry_scope(registry)
select_data_cmd = PostgresSelectCommand(
table='credential',
- conditions=['user_name = %s', 'profile = %s'],
- condition_args=[user, registry])
+ conditions=['user_name = %s', 'cred_type = %s', 'profile = %s'],
+ condition_args=[user, CredentialType.REGISTRY.value, registry])
row = self.execute_fetch_command(*select_data_cmd.get_args())
if row:
return self.decrypt_credential(row[0])
else:
return None
+ def get_all_registry_creds(self, user: str) -> Dict[str, Dict[str, str]]:
+ """ Fetch all Docker registry credentials for a user by scope. """
+ select_data_cmd = PostgresSelectCommand(
+ table='credential',
+ conditions=['user_name = %s', 'cred_type = %s'],
+ condition_args=[user, CredentialType.REGISTRY.value])
+ rows = self.execute_fetch_command(*select_data_cmd.get_args())
+ registry_creds: Dict[str, Dict[str, str]] = {}
+ for row in rows:
+ if row.profile:
+ registry_scope = common.normalize_registry_scope(row.profile)
+ registry_creds[registry_scope] = self.decrypt_credential(row)
+ return registry_creds
+
+ def get_matching_registry_creds(
+ self,
+ user: str,
+ image_info: common.DockerImageInfo,
+ ) -> List[Tuple[str, Dict[str, str]]]:
+ """ Fetch all Docker registry credentials matching an image. """
+ select_data_cmd = PostgresSelectCommand(
+ table='credential',
+ conditions=['user_name = %s', 'cred_type = %s'],
+ condition_args=[user, CredentialType.REGISTRY.value])
+ rows = self.execute_fetch_command(*select_data_cmd.get_args())
+
+ registry_rows: Dict[str, List[Any]] = {}
+ for row in rows:
+ if row.profile:
+ registry_scope = common.normalize_registry_scope(row.profile)
+ registry_rows.setdefault(registry_scope, []).append(row)
+
+ return [
+ (registry_scope, self.decrypt_credential(row))
+ for registry_scope in common.matching_registry_scopes(
+ image_info, registry_rows.keys())
+ for row in registry_rows[registry_scope]
+ ]
+
def get_workflow_service_url(self) -> str:
""" Get the workflow service url. """
service_config = self.get_service_configs()
diff --git a/src/utils/job/task.py b/src/utils/job/task.py
index 1c5e6343c..2f3fcb552 100644
--- a/src/utils/job/task.py
+++ b/src/utils/job/task.py
@@ -98,6 +98,9 @@ def create_login_dict(user: str,
}
+def docker_auth(username: str, password: str) -> str:
+ return base64.b64encode(f'{username}:{password}'.encode('utf-8')).decode('utf-8')
+
def create_config_dict(
data_info: Mapping[str, credentials.StaticDataCredential | credentials.DefaultDataCredential],
@@ -2494,17 +2497,16 @@ def _get_image_secret_name(self, group_uid: str, name: str):
def _get_registry_creds(self, user: str, workflow_config: connectors.WorkflowConfig):
""" Got registry credentials for both user and osmo. """
registry_creds_user = {}
- registry_cred_cache: Dict[str, Any] = {}
+ registry_cred_map = self.database.get_all_registry_creds(user)
for task in self.spec.tasks:
image_info = common.docker_parse(task.image)
- if image_info.host not in registry_cred_cache:
- registry_cred_cache[image_info.host] = self.database.get_registry_cred(
- user, image_info.host)
- payload = registry_cred_cache[image_info.host]
- if payload:
- auth_string = f'''{payload['username']}:{payload['auth']}'''
- registry_creds_user[image_info.host] = \
- {'auth': base64.b64encode(auth_string.encode('utf-8')).decode('utf-8')}
+ for registry_scope in common.matching_registry_scopes(
+ image_info, registry_cred_map.keys()
+ ):
+ payload = registry_cred_map[registry_scope]
+ normalized_scope = common.normalize_registry_scope(registry_scope)
+ registry_creds_user[normalized_scope] = \
+ {'auth': docker_auth(payload['username'], payload['auth'])}
registry_cred_osmo = None
osmo_cred = workflow_config.backend_images.credential
@@ -2514,11 +2516,10 @@ def _get_registry_creds(self, user: str, workflow_config: connectors.WorkflowCon
and osmo_cred.username
and osmo_cred.auth.get_secret_value()
):
- auth_string = (
- f'{osmo_cred.username}:{osmo_cred.auth.get_secret_value()}')
+ registry_scope = common.normalize_registry_scope(osmo_cred.registry)
registry_cred_osmo = {
- osmo_cred.registry: {
- 'auth': base64.b64encode(auth_string.encode('utf-8')).decode('utf-8')
+ registry_scope: {
+ 'auth': docker_auth(osmo_cred.username, osmo_cred.auth.get_secret_value())
}
}
return registry_creds_user, registry_cred_osmo
diff --git a/src/utils/job/tests/test_task.py b/src/utils/job/tests/test_task.py
index 520a2a050..682170d9f 100644
--- a/src/utils/job/tests/test_task.py
+++ b/src/utils/job/tests/test_task.py
@@ -482,6 +482,85 @@ def _make_group(ignore_nonlead: bool = True) -> task.TaskGroup:
)
+class TaskGroupRegistryCredsTest(unittest.TestCase):
+ """Tests for Docker registry credential selection."""
+
+ def _make_group(self, images: List[str]) -> task.TaskGroup:
+ spec = task.TaskGroupSpec(
+ name='test-group',
+ tasks=[
+ task.TaskSpec(
+ name=f'task-{index}',
+ image=image,
+ command=['echo'],
+ lead=index == 0,
+ )
+ for index, image in enumerate(images)
+ ],
+ )
+ return task.TaskGroup(
+ name='test-group',
+ group_uuid=common.generate_unique_id(),
+ spec=spec,
+ tasks=[],
+ remaining_upstream_groups=set(),
+ downstream_groups=set(),
+ database=mock.create_autospec(connectors.PostgresConnector, instance=True),
+ )
+
+ def test_get_registry_creds_includes_all_matching_path_scopes(self):
+ group = self._make_group(['nvcr.io/nvstaging/osmo/app:latest'])
+ cast(mock.MagicMock, group.database.get_all_registry_creds).return_value = {
+ 'nvcr.io': {'username': 'root-user', 'auth': 'root-token'},
+ 'nvcr.io/nvstaging/osmo': {'username': 'osmo-user', 'auth': 'osmo-token'},
+ 'nvcr.io/nvstaging/isaac': {'username': 'isaac-user', 'auth': 'isaac-token'},
+ }
+ workflow_config = mock.MagicMock()
+ workflow_config.backend_images.credential = None
+
+ registry_creds_user, registry_cred_osmo = group._get_registry_creds(
+ 'alice', workflow_config)
+
+ self.assertIsNone(registry_cred_osmo)
+ self.assertEqual(registry_creds_user, {
+ 'nvcr.io': {'auth': task.docker_auth('root-user', 'root-token')},
+ 'nvcr.io/nvstaging/osmo': {'auth': task.docker_auth('osmo-user', 'osmo-token')},
+ })
+
+ def test_get_registry_creds_handles_different_paths_same_host(self):
+ group = self._make_group([
+ 'nvcr.io/nvstaging/osmo/app:latest',
+ 'nvcr.io/nvidia/toolkit:latest',
+ ])
+ cast(mock.MagicMock, group.database.get_all_registry_creds).return_value = {
+ 'nvcr.io/nvstaging': {'username': 'staging-user', 'auth': 'staging-token'},
+ 'nvcr.io/nvidia': {'username': 'nvidia-user', 'auth': 'nvidia-token'},
+ }
+ workflow_config = mock.MagicMock()
+ workflow_config.backend_images.credential = None
+
+ registry_creds_user, _ = group._get_registry_creds('alice', workflow_config)
+
+ self.assertEqual(registry_creds_user, {
+ 'nvcr.io/nvstaging': {'auth': task.docker_auth('staging-user', 'staging-token')},
+ 'nvcr.io/nvidia': {'auth': task.docker_auth('nvidia-user', 'nvidia-token')},
+ })
+
+ def test_get_registry_creds_normalizes_osmo_credential_scope(self):
+ group = self._make_group(['ubuntu:latest'])
+ cast(mock.MagicMock, group.database.get_all_registry_creds).return_value = {}
+ workflow_config = mock.MagicMock()
+ workflow_config.backend_images.credential.registry = 'https://nvcr.io/osmo/'
+ workflow_config.backend_images.credential.username = 'osmo-user'
+ workflow_config.backend_images.credential.auth.get_secret_value.return_value = 'osmo-token'
+
+ _, registry_cred_osmo = group._get_registry_creds('alice', workflow_config)
+
+ self.assertEqual(registry_cred_osmo, {
+ 'nvcr.io/osmo': {'auth': task.docker_auth('osmo-user', 'osmo-token')},
+ })
+
+
class AggregateStatusTest(unittest.TestCase):
"""Tests for TaskGroup._aggregate_status with lightweight summary rows."""
diff --git a/src/utils/job/tests/test_task_pure.py b/src/utils/job/tests/test_task_pure.py
index ad27dfbba..bb5ad91cb 100644
--- a/src/utils/job/tests/test_task_pure.py
+++ b/src/utils/job/tests/test_task_pure.py
@@ -17,17 +17,40 @@
"""
import copy
import datetime
+import types
+from typing import Any
import unittest
from unittest import mock
import pydantic
-from src.lib.utils import credentials, osmo_errors, priority as wf_priority
+from src.lib.utils import common, credentials, osmo_errors, priority as wf_priority
from src.lib.utils.osmo_errors import OSMOResourceError
from src.utils import connectors
from src.utils.job import jobs, task, kb_objects
+class _TestPostgresConnector(connectors.PostgresConnector):
+ """PostgresConnector test double that does not open a database connection."""
+
+ def __init__(self, rows: list[types.SimpleNamespace]):
+ self.rows = rows
+ self.decrypt_credential_mock = mock.Mock(
+ side_effect=lambda row: {'username': row.profile, 'auth': 'token'})
+
+ def execute_fetch_command(
+ self,
+ command: str,
+ args: tuple[Any, ...],
+ return_raw: bool = False,
+ ) -> list[Any]:
+ # pylint: disable=unused-argument
+ return self.rows
+
+ def decrypt_credential(self, db_row: Any) -> dict[Any, Any]:
+ return self.decrypt_credential_mock(db_row)
+
+
class ShortenNameToFitKbTest(unittest.TestCase):
"""Pure-function tests for shorten_name_to_fit_kb."""
@@ -57,6 +80,51 @@ def test_long_name_strips_multiple_trailing_specials(self):
self.assertEqual(task.shorten_name_to_fit_kb(name), 'a' * 60)
+class PostgresRegistryCredsTest(unittest.TestCase):
+ """Pure tests for registry credential lookup helpers."""
+
+ def test_get_all_registry_creds_normalizes_profile_keys(self):
+ database = _TestPostgresConnector([
+ types.SimpleNamespace(profile='registry.example.com:443/team-a'),
+ ])
+
+ result = database.get_all_registry_creds('alice')
+
+ self.assertEqual(result, {
+ 'registry.example.com/team-a': {
+ 'username': 'registry.example.com:443/team-a',
+ 'auth': 'token',
+ },
+ })
+
+ def test_get_matching_registry_creds_decrypts_only_matching_rows(self):
+ matching_row = types.SimpleNamespace(profile='registry.example.com:443/team-a')
+ host_row = types.SimpleNamespace(profile='registry.example.com')
+ unrelated_row = types.SimpleNamespace(profile='registry.example.com/team-b')
+ database = _TestPostgresConnector([matching_row, host_row, unrelated_row])
+
+ result = database.get_matching_registry_creds(
+ 'alice',
+ common.docker_parse('registry.example.com/team-a/client:latest'),
+ )
+
+ self.assertEqual(result, [
+ ('registry.example.com/team-a', {
+ 'username': 'registry.example.com:443/team-a',
+ 'auth': 'token',
+ }),
+ ('registry.example.com', {
+ 'username': 'registry.example.com',
+ 'auth': 'token',
+ }),
+ ])
+ database.decrypt_credential_mock.assert_has_calls([
+ mock.call(matching_row),
+ mock.call(host_row),
+ ])
+ self.assertEqual(database.decrypt_credential_mock.call_count, 2)
+
+
class CreateLoginDictTest(unittest.TestCase):
"""Tests for create_login_dict token vs dev login branches."""
diff --git a/src/utils/job/tests/test_workflow_helpers.py b/src/utils/job/tests/test_workflow_helpers.py
index 816c8099b..19250dc07 100644
--- a/src/utils/job/tests/test_workflow_helpers.py
+++ b/src/utils/job/tests/test_workflow_helpers.py
@@ -17,6 +17,7 @@
"""
import datetime
import unittest
+from unittest import mock
import pydantic
@@ -225,6 +226,58 @@ def test_assertion_without_jinja_tokens_classified_as_static(self):
self.assertEqual(k8_rules, [])
+class WorkflowSpecValidateRegistryTest(unittest.TestCase):
+ """Tests for registry credential validation."""
+
+ def _workflow_spec(self) -> workflow.WorkflowSpec:
+ return workflow.WorkflowSpec(
+ name='wf',
+ tasks=[{
+ 'name': 'task',
+ 'image': 'nvcr.io/nvstaging/osmo/app:latest',
+ 'command': ['echo'],
+ }],
+ )
+
+ def test_validate_registry_uses_matching_path_scoped_credential(self):
+ spec = self._workflow_spec()
+ unauthenticated_response = mock.Mock(status_code=401)
+ authenticated_response = mock.Mock(status_code=200)
+ database = mock.Mock()
+ database.get_matching_registry_creds.return_value = [
+ ('nvcr.io/nvstaging/osmo', {'username': 'user', 'auth': 'token'}),
+ ]
+
+ with mock.patch(
+ 'src.utils.job.workflow.common.registry_auth',
+ side_effect=[unauthenticated_response, authenticated_response],
+ ) as registry_auth, mock.patch(
+ 'src.utils.job.workflow.connectors.PostgresConnector.get_instance',
+ return_value=database,
+ ):
+ response = spec.validate_registry('alice', spec.tasks[0], {}, [])
+
+ self.assertIs(response, authenticated_response)
+ registry_auth.assert_has_calls([
+ mock.call('https://nvcr.io:443/v2/nvstaging/osmo/app/manifests/latest'),
+ mock.call(
+ 'https://nvcr.io:443/v2/nvstaging/osmo/app/manifests/latest',
+ 'user',
+ 'token',
+ ),
+ ])
+
+ def test_validate_registry_disabled_parent_scope_skips_validation(self):
+ spec = self._workflow_spec()
+
+ with mock.patch('src.utils.job.workflow.common.registry_auth') as registry_auth:
+ response = spec.validate_registry(
+ 'alice', spec.tasks[0], {}, ['nvcr.io/nvstaging'])
+
+ self.assertIsNone(response)
+ registry_auth.assert_not_called()
+
+
class WorkflowSpecValidateTasksGroupsTest(unittest.TestCase):
def _minimal_task(self, name: str) -> dict:
return {'name': name, 'image': 'img', 'command': ['cmd']}
diff --git a/src/utils/job/workflow.py b/src/utils/job/workflow.py
index 4c126d2db..ea3d78dfb 100644
--- a/src/utils/job/workflow.py
+++ b/src/utils/job/workflow.py
@@ -611,7 +611,10 @@ def validate_registry(self, user: str,
image_info = common.docker_parse(group_task.image)
# Check if registry needs to be validated
- if image_info.host in disabled_registries:
+ if any(
+ common.registry_scope_matches_image(disabled_registry, image_info)
+ for disabled_registry in disabled_registries
+ ):
return None
if image_info.manifest_url in seen_registries:
@@ -623,11 +626,9 @@ def validate_registry(self, user: str,
seen_registries[image_info.manifest_url] = response
return response
- # Authenticate with user credential
- registry_cred = connectors.PostgresConnector.get_instance()\
- .get_registry_cred(user, image_info.host)
-
- if registry_cred:
+ # Authenticate with matching user credentials
+ for _, registry_cred in connectors.PostgresConnector.get_instance()\
+ .get_matching_registry_creds(user, image_info):
response = common.registry_auth(image_info.manifest_url,
registry_cred['username'],
registry_cred['auth'])
@@ -635,8 +636,9 @@ def validate_registry(self, user: str,
seen_registries[image_info.manifest_url] = response
return response
+ image_scope = common.image_registry_scope(image_info)
error_msgs = f'Unable to authenticate for pulling image {group_task.image}. ' +\
- f'Please create a credential for {image_info.host} ' +\
+ f'Please create a credential matching {image_scope} ' +\
'or check if the image exists.'
raise osmo_errors.OSMOCredentialError(error_msgs)