diff --git a/.nextchanges/bundles/6553.md b/.nextchanges/bundles/6553.md new file mode 100644 index 00000000000..1f7eaea1b79 --- /dev/null +++ b/.nextchanges/bundles/6553.md @@ -0,0 +1 @@ +* Added PyDABs (Python) support for secrets: `Resources.add_secret` and the `secret_mutator` decorator. ([#6553](https://github.com/databricks/cli/pull/6553)) diff --git a/acceptance/bundle/python/secrets-support/databricks.yml b/acceptance/bundle/python/secrets-support/databricks.yml new file mode 100644 index 00000000000..4b4f4fe3924 --- /dev/null +++ b/acceptance/bundle/python/secrets-support/databricks.yml @@ -0,0 +1,23 @@ +bundle: + name: my_project + +sync: {paths: []} # don't need to copy files + +variables: + secret_value: + description: The value of the secret + +python: + resources: + - "resources:load_resources" + mutators: + - "mutators:update_secret" + +resources: + secrets: + my_secret_1: + catalog_name: main + schema_name: default + name: my_secret_1 + value: ${var.secret_value} + expire_time: "2030-01-01T00:00:00Z" diff --git a/acceptance/bundle/python/secrets-support/mutators.py b/acceptance/bundle/python/secrets-support/mutators.py new file mode 100644 index 00000000000..b8d71911a43 --- /dev/null +++ b/acceptance/bundle/python/secrets-support/mutators.py @@ -0,0 +1,11 @@ +from dataclasses import replace + +from databricks.bundles.secrets import Secret +from databricks.bundles.core import secret_mutator + + +@secret_mutator +def update_secret(secret: Secret) -> Secret: + assert isinstance(secret.name, str) + + return replace(secret, name=f"{secret.name} (updated)") diff --git a/acceptance/bundle/python/secrets-support/out.test.toml b/acceptance/bundle/python/secrets-support/out.test.toml new file mode 100644 index 00000000000..8feac676720 --- /dev/null +++ b/acceptance/bundle/python/secrets-support/out.test.toml @@ -0,0 +1,4 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.DMS = ["", "true"] +EnvMatrix.PYDAB_VERSION = ["current"] diff --git a/acceptance/bundle/python/secrets-support/output.txt b/acceptance/bundle/python/secrets-support/output.txt new file mode 100644 index 00000000000..ed0e0dc9fca --- /dev/null +++ b/acceptance/bundle/python/secrets-support/output.txt @@ -0,0 +1,32 @@ + +>>> uv run [UV_ARGS] -q [CLI] bundle validate --var secret_value=my-secret-value --output json +{ + "experimental": { + "python": { + "mutators": [ + "mutators:update_secret" + ], + "resources": [ + "resources:load_resources" + ] + } + }, + "resources": { + "secrets": { + "my_secret_1": { + "catalog_name": "main", + "expire_time": "[TIMESTAMP]", + "name": "my_secret_1 (updated)", + "schema_name": "default", + "value": "[redacted]" + }, + "my_secret_2": { + "catalog_name": "main", + "expire_time": "[TIMESTAMP]", + "name": "my_secret_2 (updated)", + "schema_name": "default", + "value": "[redacted]" + } + } + } +} diff --git a/acceptance/bundle/python/secrets-support/resources.py b/acceptance/bundle/python/secrets-support/resources.py new file mode 100644 index 00000000000..b46f8586a64 --- /dev/null +++ b/acceptance/bundle/python/secrets-support/resources.py @@ -0,0 +1,18 @@ +from databricks.bundles.core import Resources + + +def load_resources() -> Resources: + resources = Resources() + + resources.add_secret( + "my_secret_2", + { + "catalog_name": "main", + "schema_name": "default", + "name": "my_secret_2", + "value": "${var.secret_value}", + "expire_time": "2030-01-01T00:00:00Z", + }, + ) + + return resources diff --git a/acceptance/bundle/python/secrets-support/script b/acceptance/bundle/python/secrets-support/script new file mode 100644 index 00000000000..3864446e68b --- /dev/null +++ b/acceptance/bundle/python/secrets-support/script @@ -0,0 +1,6 @@ + +# secrets require value to be a variable reference, so supply it at validate time. +trace uv run $UV_ARGS -q $CLI bundle validate --var secret_value=my-secret-value --output json | \ + jq "pick(.experimental.python, .resources)" + +rm -fr .databricks __pycache__ diff --git a/acceptance/bundle/python/secrets-support/test.toml b/acceptance/bundle/python/secrets-support/test.toml new file mode 100644 index 00000000000..7ce2cef8f40 --- /dev/null +++ b/acceptance/bundle/python/secrets-support/test.toml @@ -0,0 +1,7 @@ +Cloud = false # tests don't interact with APIs + +# secrets are only supported in the current version of the wheel +EnvMatrix.PYDAB_VERSION = ["current"] + +# secrets are only supported on the direct deployment engine +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/python/codegen/codegen/generated_dataclass.py b/python/codegen/codegen/generated_dataclass.py index ad4f6133aa5..34972080d63 100644 --- a/python/codegen/codegen/generated_dataclass.py +++ b/python/codegen/codegen/generated_dataclass.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: @@ -151,58 +151,38 @@ def generate_field( field_type = variable_or_type(field_type, is_required=is_required) param_type = variable_or_type(param_type, is_required=is_required) + field = GeneratedField( + field_name=field_name, + type_name=field_type, + param_type_name=param_type, + create_func_type_name=param_type, + description=prop.description, + default=None, + default_factory=None, + create_func_default="None", + experimental=is_experimental_stage(prop.stage), + deprecated=prop.deprecated or False, + ) + + # Collections default to an empty container (via a factory, to avoid a mutable + # default) and are made Optional in the "create" function; required scalars have + # no default; optional scalars default to None. if field_type.name == "VariableOrDict": - return GeneratedField( - field_name=field_name, - type_name=field_type, - param_type_name=param_type, + return replace( + field, create_func_type_name=optional_type(param_type), - description=prop.description, - default=None, default_factory="dict", - create_func_default="None", - experimental=is_experimental_stage(prop.stage), - deprecated=prop.deprecated or False, ) elif field_type.name == "VariableOrList": - return GeneratedField( - field_name=field_name, - type_name=field_type, - param_type_name=param_type, + return replace( + field, create_func_type_name=optional_type(param_type), - description=prop.description, - default=None, default_factory="list", - create_func_default="None", - experimental=is_experimental_stage(prop.stage), - deprecated=prop.deprecated or False, ) elif is_required: - return GeneratedField( - field_name=field_name, - type_name=field_type, - param_type_name=param_type, - create_func_type_name=param_type, - description=prop.description, - default=None, - default_factory=None, - create_func_default=None, - experimental=is_experimental_stage(prop.stage), - deprecated=prop.deprecated or False, - ) + return replace(field, create_func_default=None) else: - return GeneratedField( - field_name=field_name, - type_name=field_type, - param_type_name=param_type, - create_func_type_name=param_type, - description=prop.description, - default="None", - default_factory=None, - create_func_default="None", - experimental=is_experimental_stage(prop.stage), - deprecated=prop.deprecated or False, - ) + return replace(field, default="None") def optional_type(generated: GeneratedType) -> GeneratedType: diff --git a/python/codegen/codegen/generated_test_cases.py b/python/codegen/codegen/generated_test_cases.py index 8da404c8f0f..a06a475350a 100644 --- a/python/codegen/codegen/generated_test_cases.py +++ b/python/codegen/codegen/generated_test_cases.py @@ -104,7 +104,7 @@ def _is_composite(ref: str) -> bool: def _synth_scalar(name: str, hint: str) -> _Scalar: - """Placeholder value for a primitive (str -> hint, int -> 0, float -> 0.0, bool -> True). + """Placeholder value for a primitive (str -> hint, int -> 0, float -> 0.0, bool -> True, time.Time -> RFC3339 string). :param name: the primitive's schema name, e.g. "string", "int", "boolean". :param hint: enclosing field name, used as the string placeholder so examples read meaningfully. @@ -117,6 +117,9 @@ def _synth_scalar(name: str, hint: str) -> _Scalar: return _Scalar("0.0", "0.0") if name in ("boolean", "bool"): return _Scalar("True", "True") + # time.Time is generated as a str (see packages.RENAMES); serialized as RFC3339. + if name == "time.Time": + return _Scalar('"2020-01-01T00:00:00Z"', '"2020-01-01T00:00:00Z"') raise ValueError(f"Unknown primitive: {name}") @@ -154,8 +157,7 @@ def _synth_ref( schema = schemas[name] class_name = packages.get_class_name(ref) - module = packages.get_package(namespace, ref) - assert module + module = _module_of(namespace, ref) if schema.type == openapi.SchemaType.STRING: value = schema.enum[0] @@ -202,7 +204,7 @@ def _synth_object( continue if ( prop.deprecated - or _STAGE_RANK[prop.stage] + or _STAGE_RANK.get(prop.stage, 0) > _STAGE_RANK[openapi.LaunchStage.PUBLIC_PREVIEW] ): continue @@ -219,7 +221,7 @@ def _module_of(namespace: str, schema_name: str) -> str: """Python module a (non-primitive) schema's generated class lives in; asserts it exists. :param namespace: the resource's namespace; the type is generated under databricks.bundles.._models. - :param schema_name: the object/enum schema name to resolve. + :param schema_name: the object/enum schema name (or full ref) to resolve. """ module = packages.get_package(namespace, schema_name) assert module diff --git a/python/codegen/codegen/packages.py b/python/codegen/codegen/packages.py index c3741c93907..6155a94c8a7 100644 --- a/python/codegen/codegen/packages.py +++ b/python/codegen/codegen/packages.py @@ -9,7 +9,6 @@ "resources.ClusterPolicy", # interface{} "resources.Dashboard", # interface{} "resources.GenieSpace", # interface{} - "resources.Secret", # time.Time } # Only GA and public-preview resources are generated; later stages may still change. @@ -63,6 +62,9 @@ def _load_resource_namespace() -> dict[str, str]: RESOURCE_TYPES = list(RESOURCE_NAMESPACE.keys()) RENAMES = { + # time.Time is a scalar serialized as an RFC3339 string; the Go side models + # it as a string too (see libs/dyn/convert/sdk_native_types.go). + "time.Time": "str", "string": "str", "boolean": "bool", "integer": "int", @@ -72,6 +74,8 @@ def _load_resource_namespace() -> dict[str, str]: } PRIMITIVES = [ + # Treated as str + "time.Time", "string", "boolean", "integer", @@ -85,6 +89,10 @@ def _load_resource_namespace() -> dict[str, str]: def get_class_name(ref: str) -> str: name = ref.split("/")[-1] + + if name in RENAMES: + return RENAMES[name] + name = name.split(".")[-1] return RENAMES.get(name, name) diff --git a/python/codegen/codegen_tests/test_generated_test_cases.py b/python/codegen/codegen_tests/test_generated_test_cases.py index 047749b5035..03b9936595a 100644 --- a/python/codegen/codegen_tests/test_generated_test_cases.py +++ b/python/codegen/codegen_tests/test_generated_test_cases.py @@ -68,6 +68,7 @@ def test_is_composite(): ("float64", _Scalar("0.0", "0.0")), ("boolean", _Scalar("True", "True")), ("bool", _Scalar("True", "True")), + ("time.Time", _Scalar('"2020-01-01T00:00:00Z"', '"2020-01-01T00:00:00Z"')), ], ) def test_synth_scalar(name, expected): diff --git a/python/databricks/bundles/core/__init__.py b/python/databricks/bundles/core/__init__.py index 3625cf525c4..a84a2e084d3 100644 --- a/python/databricks/bundles/core/__init__.py +++ b/python/databricks/bundles/core/__init__.py @@ -35,6 +35,7 @@ "quality_monitor_mutator", "registered_model_mutator", "schema_mutator", + "secret_mutator", "secret_scope_mutator", "sql_warehouse_mutator", "synced_database_table_mutator", @@ -68,6 +69,7 @@ quality_monitor_mutator, registered_model_mutator, schema_mutator, + secret_mutator, secret_scope_mutator, sql_warehouse_mutator, synced_database_table_mutator, diff --git a/python/databricks/bundles/core/_generated/__init__.py b/python/databricks/bundles/core/_generated/__init__.py index a29e96bda72..cd6ca9f035b 100644 --- a/python/databricks/bundles/core/_generated/__init__.py +++ b/python/databricks/bundles/core/_generated/__init__.py @@ -62,6 +62,7 @@ _SecretScopeResources, secret_scope_mutator, ) +from databricks.bundles.core._generated.secrets import _SecretResources, secret_mutator from databricks.bundles.core._generated.sql_warehouses import ( _SqlWarehouseResources, sql_warehouse_mutator, @@ -103,6 +104,7 @@ "quality_monitor_mutator", "registered_model_mutator", "schema_mutator", + "secret_mutator", "secret_scope_mutator", "sql_warehouse_mutator", "synced_database_table_mutator", @@ -131,6 +133,7 @@ class _GeneratedResources( _RegisteredModelResources, _SchemaResources, _SecretScopeResources, + _SecretResources, _SqlWarehouseResources, _SyncedDatabaseTableResources, _VectorSearchEndpointResources, @@ -160,6 +163,7 @@ def _all_resource_types() -> "tuple[_ResourceType, ...]": registered_models, schemas, secret_scopes, + secrets, sql_warehouses, synced_database_tables, vector_search_endpoints, @@ -186,6 +190,7 @@ def _all_resource_types() -> "tuple[_ResourceType, ...]": registered_models._resource_type(), schemas._resource_type(), secret_scopes._resource_type(), + secrets._resource_type(), sql_warehouses._resource_type(), synced_database_tables._resource_type(), vector_search_endpoints._resource_type(), diff --git a/python/databricks/bundles/core/_generated/secrets.py b/python/databricks/bundles/core/_generated/secrets.py new file mode 100644 index 00000000000..fd99332628d --- /dev/null +++ b/python/databricks/bundles/core/_generated/secrets.py @@ -0,0 +1,115 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from collections.abc import Callable +from typing import TYPE_CHECKING, Optional, overload + +from databricks.bundles.core._bundle import Bundle +from databricks.bundles.core._location import Location +from databricks.bundles.core._resource_mutator import ResourceMutator +from databricks.bundles.core._transform import _transform + +if TYPE_CHECKING: + from databricks.bundles.core._resource_type import _ResourceType + from databricks.bundles.secrets._models.secret import Secret, SecretParam + + +def _resource_type() -> "_ResourceType": + from databricks.bundles.core._resource_type import _ResourceType + from databricks.bundles.secrets._models.secret import Secret + + return _ResourceType( + resource_type=Secret, + singular_name="secret", + plural_name="secrets", + ) + + +class _SecretResources: + """ + Generated secret accessors, mixed into Resources. + """ + + # Provided by the Resources subclass; declared here so the generated methods + # below type-check. + _resources: dict[str, dict] + + if TYPE_CHECKING: + + def add_location(self, path: tuple[str, ...], location: Location) -> None: ... + + def add_diagnostic_error( + self, + msg: str, + *, + detail: Optional[str] = None, + path: Optional[tuple[str, ...]] = None, + location: Optional[Location] = None, + ) -> None: ... + + @property + def secrets(self) -> dict[str, "Secret"]: + return self._resources["secrets"] + + def add_secret( + self, + resource_name: str, + secret: "SecretParam", + *, + location: Optional[Location] = None, + ) -> None: + """ + Adds the resource secret to the collection of resources. Resource name must be unique across all secrets. + + :param resource_name: unique identifier for the secret + :param secret: the secret to add, can be Secret or dict + :param location: optional location of the secret in the source code + """ + from databricks.bundles.secrets._models.secret import Secret + + secret = _transform(Secret, secret) + path = ("resources", "secrets", resource_name) + location = location or Location.from_stack_frame(depth=1) + + if self._resources["secrets"].get(resource_name): + self.add_diagnostic_error( + msg=f"Duplicate resource name '{resource_name}' for resource 'secret'. Resource names must be unique.", + location=location, + path=path, + ) + else: + if location: + self.add_location(path, location) + + self._resources["secrets"][resource_name] = secret + + +@overload +def secret_mutator( + function: Callable[[Bundle, "Secret"], "Secret"], +) -> ResourceMutator["Secret"]: ... + + +@overload +def secret_mutator( + function: Callable[["Secret"], "Secret"], +) -> ResourceMutator["Secret"]: ... + + +def secret_mutator(function: Callable) -> ResourceMutator["Secret"]: + """ + Decorator for defining mutator for secrets. Function should return a new instance of the secret + with the desired changes, instead of mutating the input secret. + + Example: + + .. code-block:: python + + @secret_mutator + def my_secret_mutator(bundle: Bundle, secret: Secret) -> Secret: + return replace(secret, ...) + + :param function: Function that mutates secrets. + """ + from databricks.bundles.secrets._models.secret import Secret + + return ResourceMutator(resource_type=Secret, function=function) diff --git a/python/databricks/bundles/secrets/__init__.py b/python/databricks/bundles/secrets/__init__.py new file mode 100644 index 00000000000..6bc14c2a19d --- /dev/null +++ b/python/databricks/bundles/secrets/__init__.py @@ -0,0 +1,29 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +__all__ = [ + "Lifecycle", + "LifecycleDict", + "LifecycleParam", + "Privilege", + "PrivilegeAssignment", + "PrivilegeAssignmentDict", + "PrivilegeAssignmentParam", + "PrivilegeParam", + "Secret", + "SecretDict", + "SecretParam", +] + + +from databricks.bundles.secrets._models.lifecycle import ( + Lifecycle, + LifecycleDict, + LifecycleParam, +) +from databricks.bundles.secrets._models.privilege import Privilege, PrivilegeParam +from databricks.bundles.secrets._models.privilege_assignment import ( + PrivilegeAssignment, + PrivilegeAssignmentDict, + PrivilegeAssignmentParam, +) +from databricks.bundles.secrets._models.secret import Secret, SecretDict, SecretParam diff --git a/python/databricks/bundles/secrets/_models/lifecycle.py b/python/databricks/bundles/secrets/_models/lifecycle.py new file mode 100644 index 00000000000..697776a198e --- /dev/null +++ b/python/databricks/bundles/secrets/_models/lifecycle.py @@ -0,0 +1,40 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypedDict + +from databricks.bundles.core._transform import _transform +from databricks.bundles.core._transform_to_json import _transform_to_json_value +from databricks.bundles.core._variable import VariableOrOptional + +if TYPE_CHECKING: + from typing_extensions import Self + + +@dataclass(kw_only=True) +class Lifecycle: + """""" + + prevent_destroy: VariableOrOptional[bool] = None + """ + Lifecycle setting to prevent the resource from being destroyed. + """ + + @classmethod + def from_dict(cls, value: "LifecycleDict") -> "Self": + return _transform(cls, value) + + def as_dict(self) -> "LifecycleDict": + return _transform_to_json_value(self) # type:ignore + + +class LifecycleDict(TypedDict, total=False): + """""" + + prevent_destroy: VariableOrOptional[bool] + """ + Lifecycle setting to prevent the resource from being destroyed. + """ + + +LifecycleParam = LifecycleDict | Lifecycle diff --git a/python/databricks/bundles/secrets/_models/privilege.py b/python/databricks/bundles/secrets/_models/privilege.py new file mode 100644 index 00000000000..21a52a2f112 --- /dev/null +++ b/python/databricks/bundles/secrets/_models/privilege.py @@ -0,0 +1,116 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from enum import Enum +from typing import Literal + + +class Privilege(Enum): + SELECT = "SELECT" + READ_PRIVATE_FILES = "READ_PRIVATE_FILES" + WRITE_PRIVATE_FILES = "WRITE_PRIVATE_FILES" + CREATE = "CREATE" + USAGE = "USAGE" + USE_CATALOG = "USE_CATALOG" + USE_SCHEMA = "USE_SCHEMA" + CREATE_SCHEMA = "CREATE_SCHEMA" + CREATE_VIEW = "CREATE_VIEW" + CREATE_EXTERNAL_TABLE = "CREATE_EXTERNAL_TABLE" + CREATE_MATERIALIZED_VIEW = "CREATE_MATERIALIZED_VIEW" + CREATE_FUNCTION = "CREATE_FUNCTION" + CREATE_MODEL = "CREATE_MODEL" + CREATE_CATALOG = "CREATE_CATALOG" + CREATE_MANAGED_STORAGE = "CREATE_MANAGED_STORAGE" + CREATE_EXTERNAL_LOCATION = "CREATE_EXTERNAL_LOCATION" + CREATE_STORAGE_CREDENTIAL = "CREATE_STORAGE_CREDENTIAL" + CREATE_SERVICE_CREDENTIAL = "CREATE_SERVICE_CREDENTIAL" + ACCESS = "ACCESS" + CREATE_SHARE = "CREATE_SHARE" + CREATE_RECIPIENT = "CREATE_RECIPIENT" + CREATE_PROVIDER = "CREATE_PROVIDER" + USE_SHARE = "USE_SHARE" + USE_RECIPIENT = "USE_RECIPIENT" + USE_PROVIDER = "USE_PROVIDER" + USE_MARKETPLACE_ASSETS = "USE_MARKETPLACE_ASSETS" + SET_SHARE_PERMISSION = "SET_SHARE_PERMISSION" + MODIFY = "MODIFY" + REFRESH = "REFRESH" + EXECUTE = "EXECUTE" + READ_FILES = "READ_FILES" + WRITE_FILES = "WRITE_FILES" + CREATE_TABLE = "CREATE_TABLE" + ALL_PRIVILEGES = "ALL_PRIVILEGES" + CREATE_CONNECTION = "CREATE_CONNECTION" + USE_CONNECTION = "USE_CONNECTION" + APPLY_TAG = "APPLY_TAG" + CREATE_FOREIGN_CATALOG = "CREATE_FOREIGN_CATALOG" + CREATE_FOREIGN_SECURABLE = "CREATE_FOREIGN_SECURABLE" + MANAGE_ALLOWLIST = "MANAGE_ALLOWLIST" + CREATE_VOLUME = "CREATE_VOLUME" + CREATE_EXTERNAL_VOLUME = "CREATE_EXTERNAL_VOLUME" + READ_VOLUME = "READ_VOLUME" + WRITE_VOLUME = "WRITE_VOLUME" + MANAGE = "MANAGE" + BROWSE = "BROWSE" + CREATE_CLEAN_ROOM = "CREATE_CLEAN_ROOM" + MODIFY_CLEAN_ROOM = "MODIFY_CLEAN_ROOM" + EXECUTE_CLEAN_ROOM_TASK = "EXECUTE_CLEAN_ROOM_TASK" + EXTERNAL_USE_SCHEMA = "EXTERNAL_USE_SCHEMA" + READ_METADATA = "READ_METADATA" + + +PrivilegeParam = ( + Literal[ + "SELECT", + "READ_PRIVATE_FILES", + "WRITE_PRIVATE_FILES", + "CREATE", + "USAGE", + "USE_CATALOG", + "USE_SCHEMA", + "CREATE_SCHEMA", + "CREATE_VIEW", + "CREATE_EXTERNAL_TABLE", + "CREATE_MATERIALIZED_VIEW", + "CREATE_FUNCTION", + "CREATE_MODEL", + "CREATE_CATALOG", + "CREATE_MANAGED_STORAGE", + "CREATE_EXTERNAL_LOCATION", + "CREATE_STORAGE_CREDENTIAL", + "CREATE_SERVICE_CREDENTIAL", + "ACCESS", + "CREATE_SHARE", + "CREATE_RECIPIENT", + "CREATE_PROVIDER", + "USE_SHARE", + "USE_RECIPIENT", + "USE_PROVIDER", + "USE_MARKETPLACE_ASSETS", + "SET_SHARE_PERMISSION", + "MODIFY", + "REFRESH", + "EXECUTE", + "READ_FILES", + "WRITE_FILES", + "CREATE_TABLE", + "ALL_PRIVILEGES", + "CREATE_CONNECTION", + "USE_CONNECTION", + "APPLY_TAG", + "CREATE_FOREIGN_CATALOG", + "CREATE_FOREIGN_SECURABLE", + "MANAGE_ALLOWLIST", + "CREATE_VOLUME", + "CREATE_EXTERNAL_VOLUME", + "READ_VOLUME", + "WRITE_VOLUME", + "MANAGE", + "BROWSE", + "CREATE_CLEAN_ROOM", + "MODIFY_CLEAN_ROOM", + "EXECUTE_CLEAN_ROOM_TASK", + "EXTERNAL_USE_SCHEMA", + "READ_METADATA", + ] + | Privilege +) diff --git a/python/databricks/bundles/secrets/_models/privilege_assignment.py b/python/databricks/bundles/secrets/_models/privilege_assignment.py new file mode 100644 index 00000000000..443fa0120c7 --- /dev/null +++ b/python/databricks/bundles/secrets/_models/privilege_assignment.py @@ -0,0 +1,53 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, TypedDict + +from databricks.bundles.core._transform import _transform +from databricks.bundles.core._transform_to_json import _transform_to_json_value +from databricks.bundles.core._variable import VariableOrList, VariableOrOptional +from databricks.bundles.secrets._models.privilege import Privilege, PrivilegeParam + +if TYPE_CHECKING: + from typing_extensions import Self + + +@dataclass(kw_only=True) +class PrivilegeAssignment: + """""" + + principal: VariableOrOptional[str] = None + """ + The principal (user email address or group name). + For deleted principals, `principal` is empty while `principal_id` is populated. + """ + + privileges: VariableOrList[Privilege] = field(default_factory=list) + """ + The privileges assigned to the principal. + """ + + @classmethod + def from_dict(cls, value: "PrivilegeAssignmentDict") -> "Self": + return _transform(cls, value) + + def as_dict(self) -> "PrivilegeAssignmentDict": + return _transform_to_json_value(self) # type:ignore + + +class PrivilegeAssignmentDict(TypedDict, total=False): + """""" + + principal: VariableOrOptional[str] + """ + The principal (user email address or group name). + For deleted principals, `principal` is empty while `principal_id` is populated. + """ + + privileges: VariableOrList[PrivilegeParam] + """ + The privileges assigned to the principal. + """ + + +PrivilegeAssignmentParam = PrivilegeAssignmentDict | PrivilegeAssignment diff --git a/python/databricks/bundles/secrets/_models/secret.py b/python/databricks/bundles/secrets/_models/secret.py new file mode 100644 index 00000000000..b0db4c03ada --- /dev/null +++ b/python/databricks/bundles/secrets/_models/secret.py @@ -0,0 +1,136 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, TypedDict + +from databricks.bundles.core._resource import Resource +from databricks.bundles.core._transform import _transform +from databricks.bundles.core._transform_to_json import _transform_to_json_value +from databricks.bundles.core._variable import ( + VariableOr, + VariableOrList, + VariableOrOptional, +) +from databricks.bundles.secrets._models.lifecycle import Lifecycle, LifecycleParam +from databricks.bundles.secrets._models.privilege_assignment import ( + PrivilegeAssignment, + PrivilegeAssignmentParam, +) + +if TYPE_CHECKING: + from typing_extensions import Self + + +@dataclass(kw_only=True) +class Secret(Resource): + """ + A secret stored in Unity Catalog. Secrets are three-level namespace objects + (catalog.schema.secret) that securely store sensitive credential data such as + passwords, tokens, and keys. + """ + + catalog_name: VariableOr[str] + """ + The name of the catalog where the schema and the secret reside. + """ + + name: VariableOr[str] + """ + The name of the secret, relative to its parent schema. + """ + + schema_name: VariableOr[str] + """ + The name of the schema where the secret resides. + """ + + value: VariableOr[str] + """ + The secret value to store. Must be a variable reference (e.g. ${var.my_secret}) to prevent plain-text secrets in configuration files. + """ + + comment: VariableOrOptional[str] = None + """ + User-provided free-form text description of the secret. + """ + + expire_time: VariableOrOptional[str] = None + """ + User-provided expiration time of the secret. Purely informational; does not trigger automatic actions. + """ + + grants: VariableOrList[PrivilegeAssignment] = field(default_factory=list) + """ + The grants to apply on this secret. + """ + + lifecycle: VariableOrOptional[Lifecycle] = None + """ + Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed. + """ + + owner: VariableOrOptional[str] = None + """ + The owner of the secret. Defaults to the creating principal on creation. Can be updated to + transfer ownership of the secret to another principal. + """ + + @classmethod + def from_dict(cls, value: "SecretDict") -> "Self": + return _transform(cls, value) + + def as_dict(self) -> "SecretDict": + return _transform_to_json_value(self) # type:ignore + + +class SecretDict(TypedDict, total=False): + """""" + + catalog_name: VariableOr[str] + """ + The name of the catalog where the schema and the secret reside. + """ + + name: VariableOr[str] + """ + The name of the secret, relative to its parent schema. + """ + + schema_name: VariableOr[str] + """ + The name of the schema where the secret resides. + """ + + value: VariableOr[str] + """ + The secret value to store. Must be a variable reference (e.g. ${var.my_secret}) to prevent plain-text secrets in configuration files. + """ + + comment: VariableOrOptional[str] + """ + User-provided free-form text description of the secret. + """ + + expire_time: VariableOrOptional[str] + """ + User-provided expiration time of the secret. Purely informational; does not trigger automatic actions. + """ + + grants: VariableOrList[PrivilegeAssignmentParam] + """ + The grants to apply on this secret. + """ + + lifecycle: VariableOrOptional[LifecycleParam] + """ + Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed. + """ + + owner: VariableOrOptional[str] + """ + The owner of the secret. Defaults to the creating principal on creation. Can be updated to + transfer ownership of the secret to another principal. + """ + + +SecretParam = SecretDict | Secret diff --git a/python/databricks_tests/core/_generated/__init__.py b/python/databricks_tests/core/_generated/__init__.py index 7b2632a44b0..138ed2e29ef 100644 --- a/python/databricks_tests/core/_generated/__init__.py +++ b/python/databricks_tests/core/_generated/__init__.py @@ -19,6 +19,7 @@ registered_models, schemas, secret_scopes, + secrets, sql_warehouses, synced_database_tables, vector_search_endpoints, @@ -47,6 +48,7 @@ registered_models._test_case(), schemas._test_case(), secret_scopes._test_case(), + secrets._test_case(), sql_warehouses._test_case(), synced_database_tables._test_case(), vector_search_endpoints._test_case(), diff --git a/python/databricks_tests/core/_generated/secrets.py b/python/databricks_tests/core/_generated/secrets.py new file mode 100644 index 00000000000..48680153209 --- /dev/null +++ b/python/databricks_tests/core/_generated/secrets.py @@ -0,0 +1,34 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from databricks.bundles.core import Resources, secret_mutator +from databricks.bundles.core._generated.secrets import _resource_type +from databricks.bundles.secrets._models.lifecycle import Lifecycle +from databricks.bundles.secrets._models.privilege_assignment import PrivilegeAssignment +from databricks.bundles.secrets._models.secret import Secret +from databricks_tests.core._resource_test_case import TestCase + + +def _test_case(): + return ( + TestCase( + add_resource=Resources.add_secret, + dict_example={ + "catalog_name": "catalog_name", + "grants": [{}], + "lifecycle": {}, + "name": "name", + "schema_name": "schema_name", + "value": "value", + }, + dataclass_example=Secret( + catalog_name="catalog_name", + grants=[PrivilegeAssignment()], + lifecycle=Lifecycle(), + name="name", + schema_name="schema_name", + value="value", + ), + mutator=secret_mutator, + ), + _resource_type(), + ) diff --git a/python/databricks_tests/core/_resource_test_case.py b/python/databricks_tests/core/_resource_test_case.py index a9755e8e95c..01eb0d78bf3 100644 --- a/python/databricks_tests/core/_resource_test_case.py +++ b/python/databricks_tests/core/_resource_test_case.py @@ -6,6 +6,8 @@ @dataclass(kw_only=True) class TestCase: + __test__ = False # not a pytest test class despite the name + add_resource: Callable dict_example: dict dataclass_example: Resource diff --git a/python/databricks_tests/core/public_api.txt b/python/databricks_tests/core/public_api.txt index 679ec523a9f..f5bdf488418 100644 --- a/python/databricks_tests/core/public_api.txt +++ b/python/databricks_tests/core/public_api.txt @@ -34,6 +34,7 @@ __all__ = [ quality_monitor_mutator, registered_model_mutator, schema_mutator, + secret_mutator, secret_scope_mutator, sql_warehouse_mutator, synced_database_table_mutator, @@ -105,6 +106,7 @@ class Resources: def add_resource(self, resource_name: str, resource: Resource, *, location: Union[Location, None] = None) -> None def add_resources(self, other: Resources) -> None def add_schema(self, resource_name: str, schema: SchemaParam, *, location: Union[Location, None] = None) -> None + def add_secret(self, resource_name: str, secret: SecretParam, *, location: Union[Location, None] = None) -> None def add_secret_scope(self, resource_name: str, secret_scope: SecretScopeParam, *, location: Union[Location, None] = None) -> None def add_sql_warehouse(self, resource_name: str, sql_warehouse: SqlWarehouseParam, *, location: Union[Location, None] = None) -> None def add_synced_database_table(self, resource_name: str, synced_database_table: SyncedDatabaseTableParam, *, location: Union[Location, None] = None) -> None @@ -130,6 +132,7 @@ class Resources: @property registered_models -> dict[str, RegisteredModel] @property schemas -> dict[str, Schema] @property secret_scopes -> dict[str, SecretScope] + @property secrets -> dict[str, Secret] @property sql_warehouses -> dict[str, SqlWarehouse] @property synced_database_tables -> dict[str, SyncedDatabaseTable] @property vector_search_endpoints -> dict[str, VectorSearchEndpoint] @@ -229,6 +232,10 @@ def registered_model_mutator(function: Callable) -> ResourceMutator[RegisteredMo @overload def schema_mutator(function: Callable[[Schema], Schema]) -> ResourceMutator[Schema] def schema_mutator(function: Callable) -> ResourceMutator[Schema] +@overload def secret_mutator(function: Callable[[Bundle, Secret], Secret]) -> ResourceMutator[Secret] +@overload def secret_mutator(function: Callable[[Secret], Secret]) -> ResourceMutator[Secret] +def secret_mutator(function: Callable) -> ResourceMutator[Secret] + @overload def secret_scope_mutator(function: Callable[[Bundle, SecretScope], SecretScope]) -> ResourceMutator[SecretScope] @overload def secret_scope_mutator(function: Callable[[SecretScope], SecretScope]) -> ResourceMutator[SecretScope] def secret_scope_mutator(function: Callable) -> ResourceMutator[SecretScope] @@ -273,6 +280,7 @@ singular_name=pipeline plural_name=pipelines resource_type=Pipeline singular_name=quality_monitor plural_name=quality_monitors resource_type=QualityMonitor singular_name=registered_model plural_name=registered_models resource_type=RegisteredModel singular_name=schema plural_name=schemas resource_type=Schema +singular_name=secret plural_name=secrets resource_type=Secret singular_name=secret_scope plural_name=secret_scopes resource_type=SecretScope singular_name=sql_warehouse plural_name=sql_warehouses resource_type=SqlWarehouse singular_name=synced_database_table plural_name=synced_database_tables resource_type=SyncedDatabaseTable