diff --git a/.nextchanges/bundles/7081.md b/.nextchanges/bundles/7081.md new file mode 100644 index 00000000000..1307fb22e8e --- /dev/null +++ b/.nextchanges/bundles/7081.md @@ -0,0 +1 @@ +* Add support for configuring an app deploy-time `health_check` via inline `config` in bundle config. ([#7081](https://github.com/databricks/cli/pull/7081)) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 2c8056be91c..597ad51e4c4 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -115,6 +115,9 @@ resources.apps.*.config.env[*] resources.AppEnvVar ALL resources.apps.*.config.env[*].name string ALL resources.apps.*.config.env[*].value string ALL resources.apps.*.config.env[*].value_from string ALL +resources.apps.*.config.health_check *resources.AppHealthCheck ALL +resources.apps.*.config.health_check.path string ALL +resources.apps.*.config.health_check.timeout_seconds int ALL resources.apps.*.create_time string ALL resources.apps.*.creator string ALL resources.apps.*.default_git_source *apps.GitSource ALL diff --git a/acceptance/bundle/resources/apps/inline_config/app/app.py b/acceptance/bundle/resources/apps/inline_config/app/app.py index 3b38b5150a3..e8c93c8759c 100644 --- a/acceptance/bundle/resources/apps/inline_config/app/app.py +++ b/acceptance/bundle/resources/apps/inline_config/app/app.py @@ -8,3 +8,8 @@ @app.route("/") def home(): return "Hello from inline config app!" + + +@app.route("/health") +def health(): + return "OK" diff --git a/acceptance/bundle/resources/apps/inline_config/databricks.yml.tmpl b/acceptance/bundle/resources/apps/inline_config/databricks.yml.tmpl index cf95239093d..92b7406ef2f 100644 --- a/acceptance/bundle/resources/apps/inline_config/databricks.yml.tmpl +++ b/acceptance/bundle/resources/apps/inline_config/databricks.yml.tmpl @@ -14,3 +14,6 @@ resources: value: test_value - name: ANOTHER_VAR value: another_value + health_check: + path: /health + timeout_seconds: 120 diff --git a/acceptance/bundle/resources/apps/inline_config/output.txt b/acceptance/bundle/resources/apps/inline_config/output.txt index a1abf2a1c77..b6ca618a164 100644 --- a/acceptance/bundle/resources/apps/inline_config/output.txt +++ b/acceptance/bundle/resources/apps/inline_config/output.txt @@ -20,7 +20,11 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged { "name": "ANOTHER_VAR" } - ] + ], + "health_check": { + "path": "/health", + "timeout": "120s" + } } >>> [CLI] bundle destroy --auto-approve diff --git a/acceptance/bundle/resources/apps/inline_config/script b/acceptance/bundle/resources/apps/inline_config/script index 4050658d44b..0e6d8d3be7a 100755 --- a/acceptance/bundle/resources/apps/inline_config/script +++ b/acceptance/bundle/resources/apps/inline_config/script @@ -13,4 +13,4 @@ $CLI bundle run myapp > /dev/null 2>&1 DEPLOYMENT_ID=$($CLI apps get "$APP_NAME" | jq -r '.active_deployment.deployment_id') add_repl "$DEPLOYMENT_ID" DEPLOYMENT_ID -trace $CLI apps get-deployment "$APP_NAME" "$DEPLOYMENT_ID" | jq '{command, env_vars}' +trace $CLI apps get-deployment "$APP_NAME" "$DEPLOYMENT_ID" | jq '{command, env_vars, health_check}' diff --git a/bundle/appdeploy/app.go b/bundle/appdeploy/app.go index 4f590be60ac..c68459d1f49 100644 --- a/bundle/appdeploy/app.go +++ b/bundle/appdeploy/app.go @@ -8,6 +8,7 @@ import ( "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/common/types/duration" sdkapps "github.com/databricks/databricks-sdk-go/service/apps" ) @@ -45,6 +46,15 @@ func BuildDeployment(sourcePath string, config *resources.AppConfig, gitSource * } } } + + if config.HealthCheck != nil { + deployment.HealthCheck = &sdkapps.AppHealthCheck{ + Path: config.HealthCheck.Path, + } + if config.HealthCheck.TimeoutSeconds > 0 { + deployment.HealthCheck.Timeout = duration.New(time.Duration(config.HealthCheck.TimeoutSeconds) * time.Second) + } + } } return deployment diff --git a/bundle/config/resources/apps.go b/bundle/config/resources/apps.go index 76acbb29a96..b2b7fcf3498 100644 --- a/bundle/config/resources/apps.go +++ b/bundle/config/resources/apps.go @@ -19,6 +19,18 @@ type AppConfig struct { // Env contains environment variables to set for the app Env []AppEnvVar `json:"env,omitempty" yaml:"env,omitempty"` + + // HealthCheck configures the HTTP check that must pass before a deployment is considered successful + HealthCheck *AppHealthCheck `json:"health_check,omitempty" yaml:"health_check,omitempty"` +} + +// AppHealthCheck represents a deploy-time health check configuration for an app +type AppHealthCheck struct { + // Path is the HTTP path to probe (e.g., "/health") + Path string `json:"path" yaml:"path"` + + // TimeoutSeconds is how long to wait for the health check to pass before failing the deployment + TimeoutSeconds int `json:"timeout_seconds,omitempty" yaml:"timeout_seconds,omitempty"` } // AppEnvVar represents an environment variable configuration for an app diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index 22cd7cc2b4e..f089186f7fd 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -288,14 +288,15 @@ func remoteIsStarted(entry *PlanEntry) bool { } // deploymentToAppConfig extracts an AppConfig from an active deployment. -// Returns nil if the deployment has no command or env vars. +// Returns nil if the deployment has no command, env vars or health check. func deploymentToAppConfig(d *apps.AppDeployment) *resources.AppConfig { - if len(d.Command) == 0 && len(d.EnvVars) == 0 { + if len(d.Command) == 0 && len(d.EnvVars) == 0 && d.HealthCheck == nil { return nil } config := &resources.AppConfig{ - Command: d.Command, - Env: nil, + Command: d.Command, + Env: nil, + HealthCheck: nil, } if len(d.EnvVars) > 0 { config.Env = make([]resources.AppEnvVar, len(d.EnvVars)) @@ -307,6 +308,16 @@ func deploymentToAppConfig(d *apps.AppDeployment) *resources.AppConfig { } } } + if d.HealthCheck != nil { + timeoutSeconds := 0 + if d.HealthCheck.Timeout != nil { + timeoutSeconds = int(d.HealthCheck.Timeout.AsDuration().Seconds()) + } + config.HealthCheck = &resources.AppHealthCheck{ + Path: d.HealthCheck.Path, + TimeoutSeconds: timeoutSeconds, + } + } return config } diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 710e4b8373f..06431c082f0 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -364,6 +364,16 @@ resources: "value_from": "description": |- PLACEHOLDER + "health_check": + "description": |- + PLACEHOLDER + "$fields": + "path": + "description": |- + PLACEHOLDER + "timeout_seconds": + "description": |- + PLACEHOLDER "effective_budget_policy_id": "description": |- PLACEHOLDER diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index 9488299bba0..c3e8838cc2e 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -22,6 +22,7 @@ var RequiredFields = map[string][]string{ "resources.apps.*": {"name"}, "resources.apps.*.active_deployment.git_source.git_repository": {"provider", "url"}, "resources.apps.*.config.env[*]": {"name"}, + "resources.apps.*.config.health_check": {"path"}, "resources.apps.*.default_git_source.git_repository": {"provider", "url"}, "resources.apps.*.git_repository": {"provider", "url"}, "resources.apps.*.git_source.git_repository": {"provider", "url"}, diff --git a/bundle/run/app_test.go b/bundle/run/app_test.go index 76946938ebb..b6e556248ad 100644 --- a/bundle/run/app_test.go +++ b/bundle/run/app_test.go @@ -292,6 +292,10 @@ func TestBuildAppDeploymentWithValueFrom(t *testing.T) { ValueFrom: "secrets/override", }, }, + HealthCheck: &resources.AppHealthCheck{ + Path: "/health", + TimeoutSeconds: 120, + }, }, } @@ -319,4 +323,7 @@ func TestBuildAppDeploymentWithValueFrom(t *testing.T) { require.Equal(t, "COMBINED_VAR", deployment.EnvVars[2].Name) require.Equal(t, "default_value", deployment.EnvVars[2].Value) require.Equal(t, "secrets/override", deployment.EnvVars[2].ValueFrom) + + require.Equal(t, "/health", deployment.HealthCheck.Path) + require.Equal(t, 120*time.Second, deployment.HealthCheck.Timeout.AsDuration()) } diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index e8422a92b40..2997955de28 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -284,6 +284,9 @@ }, "env": { "$ref": "#/$defs/slice/github.com/databricks/cli/bundle/config/resources.AppEnvVar" + }, + "health_check": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.AppHealthCheck" } }, "additionalProperties": false @@ -320,6 +323,29 @@ } ] }, + "resources.AppHealthCheck": { + "oneOf": [ + { + "type": "object", + "properties": { + "path": { + "$ref": "#/$defs/string" + }, + "timeout_seconds": { + "$ref": "#/$defs/int" + } + }, + "additionalProperties": false, + "required": [ + "path" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.AppPermission": { "oneOf": [ { diff --git a/cmd/apps/import.go b/cmd/apps/import.go index 355c51c690e..28a0d9bd450 100644 --- a/cmd/apps/import.go +++ b/cmd/apps/import.go @@ -607,6 +607,15 @@ func inlineAppConfigFile(appValue *dyn.Value) (string, error) { configMap["env"] = envValue } + // Add health_check if present + if healthCheck, ok := appConfig["health_check"]; ok { + healthCheckValue, err := convert.FromTyped(healthCheck, dyn.NilValue) + if err != nil { + return "", fmt.Errorf("failed to convert health_check: %w", err) + } + configMap["health_check"] = healthCheckValue + } + // Add the config section if we have any items if len(configMap) > 0 { newPairs = append(newPairs, dyn.Pair{ diff --git a/cmd/apps/import_test.go b/cmd/apps/import_test.go index b50ec937991..23c4e77a36b 100644 --- a/cmd/apps/import_test.go +++ b/cmd/apps/import_test.go @@ -217,6 +217,9 @@ func TestInlineAppConfigFileFieldPriority(t *testing.T) { env: - name: FOO value: bar +health_check: + path: /health + timeout_seconds: 120 resources: - name: ENDPOINT serving_endpoint: @@ -245,6 +248,7 @@ resources: assert.Equal(t, "existing description", result["description"]) assert.NotNil(t, result["config"]) assert.NotNil(t, result["resources"]) + assert.Equal(t, map[string]any{"path": "/health", "timeout_seconds": int64(120)}, result["config"].(map[string]any)["health_check"]) }) }