diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 52ace1f5cb..ac3acaa3fa 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -1100,7 +1100,7 @@ nemo files filesets create [OPTIONS] [NAME] **Arguments:** -* ``: The name of the fileset. Allowed characters: letters (a-z, A-Z), digits (0-9), underscores, hyphens, and dots. +* ``: The name of the fileset. Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). **Options:** @@ -3144,7 +3144,7 @@ nemo inference providers create [OPTIONS] [NAME] **Arguments:** -* ``: Name of the model provider. Allowed characters: letters (a-z, A-Z), digits (0-9), underscores, hyphens, and dots. +* ``: Name of the model provider. Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). **Options:** @@ -5066,7 +5066,7 @@ nemo workspaces create [OPTIONS] [NAME] **Arguments:** -* ``: Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and contain only lowercase letters, digits, and hyphens (no consecutive hyphens, cannot end with a hyphen). +* ``: Workspace name (unique identifier). Name must start with a lowercase letter, be 2-63 characters, and use lowercase letters, digits, hyphens, and dots (no consecutive hyphens, cannot end with a hyphen). **Options:** diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index 234a7c54ae..359cbae96a 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -9416,11 +9416,12 @@ components: properties: name: type: string - maxLength: 255 - pattern: ^[\w\-.]+$ + maxLength: 63 + pattern: ^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? str: - if not _NAME_RE.match(v): - raise ValueError( - f"Invalid secret name '{v}'. Allowed characters: letters, digits, underscores, " - "hyphens, and dots. Example: my-api-key" - ) - return v - @field_serializer("value", when_used="json") def _serialize_value(self, value: SecretStr) -> str: return value.get_secret_value() diff --git a/packages/nmp_common/src/nmp/common/entities/constants.py b/packages/nmp_common/src/nmp/common/entities/constants.py index 5977d6baa6..cfa93945fe 100644 --- a/packages/nmp_common/src/nmp/common/entities/constants.py +++ b/packages/nmp_common/src/nmp/common/entities/constants.py @@ -3,19 +3,14 @@ """Constants for entity validation.""" -# RFC 1035 compliant pattern with temporary support for special characters. -# Allows lowercase letters, digits, hyphens, and temporarily: @, ., +, _ -# - Must start with a lowercase letter [a-z] -# - Length: 2-63 characters -# - No consecutive hyphens (--) -# - Must not end with a hyphen -# TODO(#3530): Remove @, ., +, _ once versioning is implemented and predefined target names (e.g., llama-3.2-3b-instruct@v1.0.0+A100) are updated. -NAME_PATTERN = r"^[a-z](?!.*--)[a-z0-9\-@.+_]{1,62}(? str: @@ -473,10 +473,12 @@ class ModelProviderSort(StrEnum): class CreateModelProviderRequest(BaseModel): """Request model for creating a ModelProvider.""" + model_config = ConfigDict(regex_engine="python-re") + name: str = Field( - description=f"Name of the model provider. {constants.REGEX_WORD_CHARACTER_DOT_DASH_DESCRIPTION}", - max_length=constants.MAX_LENGTH_255, - pattern=constants.REGEX_WORD_CHARACTER_DOT_DASH, + description=f"Name of the model provider. {constants.NAME_PATTERN_DESCRIPTION}", + max_length=constants.NAME_MAX_LENGTH, + pattern=constants.NAME_PATTERN, examples=["my-nim-provider", "openai-endpoint"], ) project: Optional[str] = Field( diff --git a/services/core/secrets/tests/integration/test_secrets_with_auth.py b/services/core/secrets/tests/integration/test_secrets_with_auth.py index f1caac08d6..e79a8ef353 100644 --- a/services/core/secrets/tests/integration/test_secrets_with_auth.py +++ b/services/core/secrets/tests/integration/test_secrets_with_auth.py @@ -34,7 +34,7 @@ short_unique_name, unique_email, ) -from pydantic import SecretStr +from pydantic import SecretStr, ValidationError # Service principals have elevated access (like platform admin) SERVICE_PRINCIPAL = "service:integration-test" @@ -997,9 +997,16 @@ def test_create_secret_with_uppercase_returns_422(self, sdk: NeMoPlatform): # Name with uppercase letter - violates DNS-compliant naming rules invalid_name = "test-secret-123-Test" + with pytest.raises(ValidationError) as local_exc: + PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")) + assert "should match pattern" in str(local_exc.value).lower() + + # model_construct skips validation so the name still reaches the server. with pytest.raises(ClientUnprocessableEntityError) as exc_info: admin_secrets.create_secret( - body=PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")), + body=PlatformSecretCreateRequest.model_construct( + name=invalid_name, value=SecretStr("test-value"), description=None + ), workspace="default", ) @@ -1016,9 +1023,15 @@ def test_create_secret_with_all_uppercase_returns_422(self, sdk: NeMoPlatform): admin_secrets = client_from_platform(admin_sdk, SecretsClient) invalid_name = "TEST-SECRET" + with pytest.raises(ValidationError) as local_exc: + PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")) + assert "should match pattern" in str(local_exc.value).lower() + with pytest.raises(ClientUnprocessableEntityError) as exc_info: admin_secrets.create_secret( - body=PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")), + body=PlatformSecretCreateRequest.model_construct( + name=invalid_name, value=SecretStr("test-value"), description=None + ), workspace="default", ) diff --git a/services/core/secrets/tests/test_secrets.py b/services/core/secrets/tests/test_secrets.py index ef3aed559d..6b33b264ca 100644 --- a/services/core/secrets/tests/test_secrets.py +++ b/services/core/secrets/tests/test_secrets.py @@ -65,18 +65,26 @@ async def test_create_secret_with_empty_value(test_client): assert response.status_code == 422 -@pytest.mark.parametrize("invalid_name", ["bad name", "bad/name", "no!way", "a@b"]) -async def test_create_secret_with_invalid_name_returns_friendly_error(test_client, invalid_name): - """Verify that names with disallowed characters return a 422 with a readable message.""" +@pytest.mark.parametrize( + "invalid_name", + ["bad name", "bad/name", "no!way", "MySecret", "1secret", "my--secret", "secret-", "a" * 64], +) +async def test_create_secret_with_invalid_name_is_rejected(test_client, invalid_name): + """Names the entity store would reject must fail at the API boundary, not downstream.""" response = test_client.post( "/apis/secrets/v2/workspaces/default/secrets", json={"name": invalid_name, "value": "x"}, ) assert response.status_code == 422 - detail = response.json()["detail"] - msg = detail[0]["msg"] - assert "Invalid secret name" in msg - assert invalid_name in msg + + +@pytest.mark.parametrize("valid_name", ["hf-token", "a@b", "model.v1", "svc_key", "ab"]) +async def test_create_secret_accepts_entity_store_names(test_client, valid_name): + response = test_client.post( + "/apis/secrets/v2/workspaces/default/secrets", + json={"name": valid_name, "value": "x"}, + ) + assert response.status_code == 201 async def test_create_and_delete_secret(test_client):