diff --git a/.nextchanges/bundles/job-runs-on-bundle-deploy.md b/.nextchanges/bundles/job-runs-on-bundle-deploy.md new file mode 100644 index 00000000000..21acd2891c5 --- /dev/null +++ b/.nextchanges/bundles/job-runs-on-bundle-deploy.md @@ -0,0 +1 @@ +direct: `resources.job_runs` can set `lifecycle.triggers.on_bundle_deploy: true` to re-fire the run on every bundle deploy. diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 27cd4797a6d..c64592be0dd 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -845,8 +845,12 @@ resources.job_runs.*.jar_params[*] string ALL resources.job_runs.*.job_id int64 ALL resources.job_runs.*.job_parameters map[string]string ALL resources.job_runs.*.job_parameters.* string ALL +resources.job_runs.*.lifecycle *resources.JobRunLifecycle INPUT resources.job_runs.*.lifecycle resources.Lifecycle INPUT resources.job_runs.*.lifecycle.prevent_destroy bool INPUT +resources.job_runs.*.lifecycle.triggers []resources.JobRunTrigger INPUT +resources.job_runs.*.lifecycle.triggers[*] resources.JobRunTrigger INPUT +resources.job_runs.*.lifecycle.triggers[*].on_bundle_deploy *bool INPUT resources.job_runs.*.modified_status string INPUT resources.job_runs.*.notebook_params map[string]string ALL resources.job_runs.*.notebook_params.* string ALL @@ -885,6 +889,8 @@ resources.job_runs.*.state.queue_reason string REMOTE resources.job_runs.*.state.result_state jobs.RunResultState REMOTE resources.job_runs.*.state.state_message string REMOTE resources.job_runs.*.state.user_cancelled_or_timedout bool REMOTE +resources.job_runs.*.triggers *dresources.JobRunTriggersState STATE +resources.job_runs.*.triggers.on_bundle_deploy string STATE resources.job_runs.*.url string INPUT resources.jobs.*.budget_policy_id string ALL resources.jobs.*.continuous *jobs.Continuous ALL diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/databricks.yml b/acceptance/bundle/resources/job_runs/on_bundle_deploy/databricks.yml new file mode 100644 index 00000000000..0ca892109c3 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/databricks.yml @@ -0,0 +1,18 @@ +bundle: + name: job-runs-on-bundle-deploy + +resources: + jobs: + my_job: + name: my-job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + job_runs: + my_run: + job_id: ${resources.jobs.my_job.id} + lifecycle: + triggers: + - on_bundle_deploy: true diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml b/acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt new file mode 100644 index 00000000000..36dd5bad024 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt @@ -0,0 +1,82 @@ + +=== first deploy triggers a run +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... +Deploying resources... +job run [MY_RUN_ID]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID]?o=[NUMID] +job run [MY_RUN_ID]: SUCCESS +Updating deployment state... +Deployment complete! + +>>> read_id.py my_job +[MY_JOB_ID] + +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[UUID]", + "job_id": [MY_JOB_ID] + } +} + +=== redeploy re-fires with unchanged config +>>> [CLI] bundle plan +recreate job_runs.my_run + +Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... +Deploying resources... +job run [MY_RUN_ID_2]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?o=[NUMID] +job run [MY_RUN_ID_2]: SUCCESS +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle summary +Name: job-runs-on-bundle-deploy +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default +Resources: + Job Runs: + my_run: + Name: + URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?w=[NUMID] + Jobs: + my_job: + Name: my-job + URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]?w=[NUMID] + +=== second run-now after recreate +>>> print_requests.py --keep //jobs/runs/delete +{ + "method": "POST", + "path": "/api/2.2/jobs/runs/delete", + "body": { + "run_id": [MY_RUN_ID] + } +} + +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[UUID]", + "job_id": [MY_JOB_ID] + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.job_runs.my_run + delete resources.jobs.my_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script new file mode 100644 index 00000000000..e482c1286e0 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script @@ -0,0 +1,22 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +title "first deploy triggers a run" +trace $CLI bundle deploy +trace read_id.py my_job +# Name the first run so the second becomes [MY_RUN_ID_2]. +read_id.py my_run > /dev/null +trace print_requests.py //jobs/run-now + +title "redeploy re-fires with unchanged config" +trace $CLI bundle plan +trace $CLI bundle deploy +read_id.py my_run > /dev/null +trace $CLI bundle summary + +title "second run-now after recreate" +trace print_requests.py --keep //jobs/runs/delete +trace print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml b/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml new file mode 100644 index 00000000000..e61157b3197 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml @@ -0,0 +1,3 @@ +# Restrict the matrix to direct: job_runs is a direct-engine-only resource. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +RecordRequests = true diff --git a/bundle/config/mutator/validate_job_run_triggers.go b/bundle/config/mutator/validate_job_run_triggers.go new file mode 100644 index 00000000000..a7c5ada925e --- /dev/null +++ b/bundle/config/mutator/validate_job_run_triggers.go @@ -0,0 +1,56 @@ +package mutator + +import ( + "context" + "fmt" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" +) + +type validateJobRunTriggers struct{} + +// ValidateJobRunTriggers rejects invalid lifecycle.triggers on job_runs. +func ValidateJobRunTriggers() bundle.Mutator { + return &validateJobRunTriggers{} +} + +func (*validateJobRunTriggers) Name() string { + return "ValidateJobRunTriggers" +} + +func (*validateJobRunTriggers) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { + var diags diag.Diagnostics + for name, jr := range b.Config.Resources.JobRuns { + if jr == nil || jr.Lifecycle == nil { + continue + } + // Recreate-every-deploy cannot coexist with prevent_destroy. + if jr.HasOnBundleDeploy() && jr.Lifecycle.PreventDestroy { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: "lifecycle.triggers.on_bundle_deploy is incompatible with lifecycle.prevent_destroy", + Locations: b.Config.GetLocations(fmt.Sprintf("resources.job_runs.%s.lifecycle", name)), + }) + } + for i, t := range jr.Lifecycle.Triggers { + path := fmt.Sprintf("resources.job_runs.%s.lifecycle.triggers[%d]", name, i) + if t.OnBundleDeploy == nil { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: "lifecycle.triggers entry must set on_bundle_deploy: true", + Locations: b.Config.GetLocations(path), + }) + continue + } + if !*t.OnBundleDeploy { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: "lifecycle.triggers.on_bundle_deploy must be true when set", + Locations: b.Config.GetLocations(path + ".on_bundle_deploy"), + }) + } + } + } + return diags +} diff --git a/bundle/config/mutator/validate_job_run_triggers_test.go b/bundle/config/mutator/validate_job_run_triggers_test.go new file mode 100644 index 00000000000..9c225457471 --- /dev/null +++ b/bundle/config/mutator/validate_job_run_triggers_test.go @@ -0,0 +1,82 @@ +package mutator_test + +import ( + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/mutator" + "github.com/databricks/cli/bundle/config/resources" + "github.com/stretchr/testify/assert" +) + +func TestValidateJobRunTriggers(t *testing.T) { + trueVal := true + falseVal := false + + tests := []struct { + name string + triggers []resources.JobRunTrigger + preventDestroy bool + summary string + }{ + { + name: "on_bundle_deploy true", + triggers: []resources.JobRunTrigger{ + {OnBundleDeploy: &trueVal}, + }, + }, + { + name: "empty entry", + triggers: []resources.JobRunTrigger{ + {}, + }, + summary: "lifecycle.triggers entry must set on_bundle_deploy: true", + }, + { + name: "on_bundle_deploy false", + triggers: []resources.JobRunTrigger{ + {OnBundleDeploy: &falseVal}, + }, + summary: "lifecycle.triggers.on_bundle_deploy must be true when set", + }, + { + name: "on_bundle_deploy with prevent_destroy", + triggers: []resources.JobRunTrigger{ + {OnBundleDeploy: &trueVal}, + }, + preventDestroy: true, + summary: "lifecycle.triggers.on_bundle_deploy is incompatible with lifecycle.prevent_destroy", + }, + { + name: "prevent_destroy alone", + preventDestroy: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + JobRuns: map[string]*resources.JobRun{ + "my_run": { + Lifecycle: &resources.JobRunLifecycle{ + Lifecycle: resources.Lifecycle{PreventDestroy: tt.preventDestroy}, + Triggers: tt.triggers, + }, + }, + }, + }, + }, + } + diags := bundle.Apply(t.Context(), b, mutator.ValidateJobRunTriggers()) + if tt.summary == "" { + assert.Empty(t, diags) + return + } + assert.True(t, diags.HasError()) + assert.Equal(t, tt.summary, diags[0].Summary) + }) + } +} diff --git a/bundle/config/resources/job_run.go b/bundle/config/resources/job_run.go index 8db6ced76e1..1c648403de5 100644 --- a/bundle/config/resources/job_run.go +++ b/bundle/config/resources/job_run.go @@ -14,18 +14,42 @@ import ( ) // JobRun is the bundle config for a triggered job run, described by the same -// fields as the Jobs RunNow request (embedded). It re-triggers only when its own -// config changes, not when the targeted job (stable job_id) changes. +// fields as the Jobs RunNow request (embedded). By default it re-fires when its +// own configuration changes; lifecycle.triggers can add further conditions. type JobRun struct { BaseResource jobs.RunNow + // Lifecycle shadows BaseResource.Lifecycle so job_runs can set triggers. + Lifecycle *JobRunLifecycle `json:"lifecycle,omitempty"` + // ResolvedJobID holds the run's job_id loaded from state, used only to build // the run URL. Keeping it separate from RunNow.JobId (a ${resources.jobs.*.id} // reference) lets state loading preserve that reference and its plan dependency. ResolvedJobID int64 `json:"resolved_job_id,omitempty" bundle:"internal"` } +// GetLifecycle returns the job_runs lifecycle, including triggers. +func (r *JobRun) GetLifecycle() LifecycleConfig { + if r.Lifecycle == nil { + return JobRunLifecycle{} + } + return *r.Lifecycle +} + +// HasOnBundleDeploy reports whether any trigger re-fires on every deploy. +func (r *JobRun) HasOnBundleDeploy() bool { + if r.Lifecycle == nil { + return false + } + for _, t := range r.Lifecycle.Triggers { + if t.OnBundleDeploy != nil && *t.OnBundleDeploy { + return true + } + } + return false +} + func (r *JobRun) UnmarshalJSON(b []byte) error { return marshal.Unmarshal(b, r) } diff --git a/bundle/config/resources/lifecycle.go b/bundle/config/resources/lifecycle.go index db2b130d313..88d130239ee 100644 --- a/bundle/config/resources/lifecycle.go +++ b/bundle/config/resources/lifecycle.go @@ -25,3 +25,16 @@ type LifecycleWithStarted struct { // Supported only for apps, clusters, and sql_warehouses. Started *bool `json:"started,omitempty"` } + +// JobRunLifecycle extends Lifecycle with run-fire triggers. +type JobRunLifecycle struct { + Lifecycle + + // Triggers that cause the run to re-fire (in addition to config changes). + Triggers []JobRunTrigger `json:"triggers,omitempty"` +} + +// JobRunTrigger is one lifecycle.triggers entry. +type JobRunTrigger struct { + OnBundleDeploy *bool `json:"on_bundle_deploy,omitempty"` +} diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index ac549b876cd..653f3a0220b 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -26,12 +26,21 @@ import ( // jobRunTimeout matches the timeout `bundle run` allows a run (bundle/run/job.go). const jobRunTimeout = 24 * time.Hour +// JobRunTriggersState is the persisted fingerprint of lifecycle.triggers. +type JobRunTriggersState struct { + // Fresh UUID each plan when on_bundle_deploy is set so Old!=New forces recreate. + OnBundleDeploy string `json:"on_bundle_deploy,omitempty"` +} + // JobRunState is the RunNow request plus the outcome required for planning. type JobRunState struct { jobs.RunNow // Always SUCCESS during planning and cleared before persistence. ResultState jobs.RunResultState `json:"result_state,omitempty"` + + // Local-only trigger fingerprints; listed in knownMissingInRemoteType. + Triggers *JobRunTriggersState `json:"triggers,omitempty"` } func (s *JobRunState) UnmarshalJSON(b []byte) error { @@ -79,10 +88,15 @@ func (*ResourceJobRun) New(client *databricks.WorkspaceClient) *ResourceJobRun { } func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { - return &JobRunState{ + state := &JobRunState{ RunNow: input.RunNow, ResultState: jobs.RunResultStateSuccess, + Triggers: nil, } + if input.HasOnBundleDeploy() { + state.Triggers = &JobRunTriggersState{OnBundleDeploy: uuid.NewString()} + } + return state } // makeJobRunRemote maps the GetRun response into the RunNow-shaped remote: GET @@ -158,7 +172,12 @@ func (r *ResourceJobRun) DoRead(ctx context.Context, id string) (*JobRunRemote, // RemapState extracts the fields used for diffing: the RunNow request and the // outcome the run reached. func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { - return &JobRunState{RunNow: remote.RunNow, ResultState: remote.ResultState} + return &JobRunState{ + RunNow: remote.RunNow, + ResultState: remote.ResultState, + // Local-only trigger fingerprints stay unset on the remapped remote. + Triggers: nil, + } } func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (string, *JobRunRemote, error) { diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 62a22a9745f..34f1ede4d80 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -342,6 +342,28 @@ func TestJobRunPrepareStateRequiresSuccess(t *testing.T) { assert.Equal(t, jobs.RunResultStateSuccess, state.ResultState) } +func TestJobRunPrepareStateOnBundleDeploy(t *testing.T) { + t.Run("unset", func(t *testing.T) { + state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{}) + assert.Nil(t, state.Triggers) + }) + + t.Run("armed", func(t *testing.T) { + on := true + input := &resources.JobRun{ + Lifecycle: &resources.JobRunLifecycle{ + Triggers: []resources.JobRunTrigger{{OnBundleDeploy: &on}}, + }, + } + first := (&ResourceJobRun{}).PrepareState(input) + require.NotNil(t, first.Triggers) + assert.NotEmpty(t, first.Triggers.OnBundleDeploy) + + second := (&ResourceJobRun{}).PrepareState(input) + assert.NotEqual(t, first.Triggers.OnBundleDeploy, second.Triggers.OnBundleDeploy) + }) +} + // The planner diffs RemapState(remote) against PrepareState(config), so a run // that did not end in SUCCESS has to surface as a difference on result_state. func TestJobRunRemapStateCarriesTheOutcome(t *testing.T) { diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 109b98c3a86..72d9c16ee8d 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -56,6 +56,10 @@ var knownMissingInRemoteType = map[string][]string{ "vector_search_endpoints": { "usage_policy_id", }, + "job_runs": { + // Local-only trigger fingerprints stored in state. + "triggers", + }, } // commonMissingInStateType lists fields that are commonly missing across all resource types. diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 8d9988a3dd1..9288872dcc1 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -973,7 +973,18 @@ resources: "$fields": "lifecycle": "description": |- - Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed. + Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed and when the run re-fires. + "$fields": + "prevent_destroy": + "description": |- + Lifecycle setting to prevent the resource from being destroyed. + "triggers": + "description": |- + Conditions that re-fire this job run (in addition to configuration changes). + "$fields": + "on_bundle_deploy": + "description": |- + If true, re-fire the run on every bundle deploy. Incompatible with lifecycle.prevent_destroy. "python_named_params": "description": |- PLACEHOLDER diff --git a/bundle/phases/initialize.go b/bundle/phases/initialize.go index a130da820d6..b15e1c30df6 100644 --- a/bundle/phases/initialize.go +++ b/bundle/phases/initialize.go @@ -188,6 +188,9 @@ func Initialize(ctx context.Context, b *bundle.Bundle) { // Reject configured job_runs.idempotency_token; the CLI sets it on run-now. validate.ValidateJobRunIdempotencyToken(), + // Reject invalid job_runs.lifecycle.triggers (empty, false, prevent_destroy). + mutator.ValidateJobRunTriggers(), + // Reads (dynamic): * (strings) (searches for ${resources.*} references) // Warns (TF engine) or errors (direct engine) when a cross-resource reference // points to a Terraform-only field with no DABs equivalent. diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 18414223b54..1ceede2ee68 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1195,8 +1195,8 @@ "$ref": "#/$defs/map/string" }, "lifecycle": { - "description": "Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed.", - "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + "description": "Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed and when the run re-fires.", + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.JobRunLifecycle" }, "notebook_params": { "description": "[Private Preview] A map from keys to values for jobs with notebook task, for example `\"notebook_params\": {\"name\": \"john doe\", \"age\": \"35\"}`.\nThe map is passed to the notebook and is accessible through the [dbutils.widgets.get](https://docs.databricks.com/dev-tools/databricks-utils.html) function.\n\nIf not specified upon `run-now`, the triggered run uses the job’s base parameters.\n\nnotebook_params cannot be specified in conjunction with jar_params.\n\n⚠ **Deprecation note** Use [job parameters](https://docs.databricks.com/jobs/job-parameters.html#job-parameter-pushdown) to pass information down to tasks.\n\nThe JSON representation of this field (for example `{\"notebook_params\":{\"name\":\"john doe\",\"age\":\"35\"}}`) cannot exceed 10,000 bytes.", @@ -1266,6 +1266,46 @@ } ] }, + "resources.JobRunLifecycle": { + "oneOf": [ + { + "type": "object", + "properties": { + "prevent_destroy": { + "description": "Lifecycle setting to prevent the resource from being destroyed.", + "$ref": "#/$defs/bool" + }, + "triggers": { + "description": "Conditions that re-fire this job run (in addition to configuration changes).", + "$ref": "#/$defs/slice/github.com/databricks/cli/bundle/config/resources.JobRunTrigger" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "resources.JobRunTrigger": { + "oneOf": [ + { + "type": "object", + "properties": { + "on_bundle_deploy": { + "description": "If true, re-fire the run on every bundle deploy. Incompatible with lifecycle.prevent_destroy.", + "$ref": "#/$defs/bool" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Lifecycle": { "oneOf": [ { @@ -15161,6 +15201,20 @@ } ] }, + "resources.JobRunTrigger": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.JobRunTrigger" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.MlflowExperimentPermission": { "oneOf": [ {