diff --git a/acceptance/bundle/managed-state/gate-config/databricks.yml b/acceptance/bundle/managed-state/gate-config/databricks.yml new file mode 100644 index 00000000000..7ef87d44c9b --- /dev/null +++ b/acceptance/bundle/managed-state/gate-config/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: test-managed-state-gate-config + managed_state: true + +targets: + default: + default: true diff --git a/acceptance/bundle/managed-state/gate-config/out.test.toml b/acceptance/bundle/managed-state/gate-config/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/bundle/managed-state/gate-config/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/managed-state/gate-config/output.txt b/acceptance/bundle/managed-state/gate-config/output.txt new file mode 100644 index 00000000000..0e903645197 --- /dev/null +++ b/acceptance/bundle/managed-state/gate-config/output.txt @@ -0,0 +1,3 @@ + +>>> [CLI] bundle validate -o json +true diff --git a/acceptance/bundle/managed-state/gate-config/script b/acceptance/bundle/managed-state/gate-config/script new file mode 100644 index 00000000000..b71b4957491 --- /dev/null +++ b/acceptance/bundle/managed-state/gate-config/script @@ -0,0 +1 @@ +trace $CLI bundle validate -o json | jq '.bundle.managed_state' diff --git a/acceptance/bundle/managed-state/gate-config/test.toml b/acceptance/bundle/managed-state/gate-config/test.toml new file mode 100644 index 00000000000..601384fdf96 --- /dev/null +++ b/acceptance/bundle/managed-state/gate-config/test.toml @@ -0,0 +1 @@ +Ignore = [".databricks"] diff --git a/acceptance/bundle/managed-state/gate-env/databricks.yml b/acceptance/bundle/managed-state/gate-env/databricks.yml new file mode 100644 index 00000000000..eb43d39bb13 --- /dev/null +++ b/acceptance/bundle/managed-state/gate-env/databricks.yml @@ -0,0 +1,6 @@ +bundle: + name: test-managed-state-gate-env + +targets: + default: + default: true diff --git a/acceptance/bundle/managed-state/gate-env/out.test.toml b/acceptance/bundle/managed-state/gate-env/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/bundle/managed-state/gate-env/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/managed-state/gate-env/output.txt b/acceptance/bundle/managed-state/gate-env/output.txt new file mode 100644 index 00000000000..2b135d54039 --- /dev/null +++ b/acceptance/bundle/managed-state/gate-env/output.txt @@ -0,0 +1,9 @@ + +>>> DATABRICKS_BUNDLE_MANAGED_STATE=true [CLI] bundle validate -o json +null + +>>> DATABRICKS_BUNDLE_MANAGED_STATE=false [CLI] bundle validate -o json +null + +>>> [CLI] bundle validate -o json +null diff --git a/acceptance/bundle/managed-state/gate-env/script b/acceptance/bundle/managed-state/gate-env/script new file mode 100644 index 00000000000..c9476dee182 --- /dev/null +++ b/acceptance/bundle/managed-state/gate-env/script @@ -0,0 +1,7 @@ +# The env var does not surface in bundle config dumps; it is consumed lazily +# at deploy/destroy/bind/unbind time via ResolveManagedStateSetting. This test +# pins that contract by showing the env var leaves .bundle.managed_state +# unset in the validated config, regardless of the env var value. +trace DATABRICKS_BUNDLE_MANAGED_STATE=true $CLI bundle validate -o json | jq '.bundle.managed_state' +trace DATABRICKS_BUNDLE_MANAGED_STATE=false $CLI bundle validate -o json | jq '.bundle.managed_state' +trace $CLI bundle validate -o json | jq '.bundle.managed_state' diff --git a/acceptance/bundle/managed-state/gate-env/test.toml b/acceptance/bundle/managed-state/gate-env/test.toml new file mode 100644 index 00000000000..601384fdf96 --- /dev/null +++ b/acceptance/bundle/managed-state/gate-env/test.toml @@ -0,0 +1 @@ +Ignore = [".databricks"] diff --git a/bundle/config/bundle.go b/bundle/config/bundle.go index ce6d25bfe62..f74d35d475d 100644 --- a/bundle/config/bundle.go +++ b/bundle/config/bundle.go @@ -50,6 +50,15 @@ type Bundle struct { // Can be overridden with the DATABRICKS_BUNDLE_ENGINE environment variable. Engine engine.EngineType `json:"engine,omitempty"` + // ManagedState opts the bundle into server-side deployment state management + // via the Deployment Metadata Service. When true, deployment locks and + // resource state are held by the control plane instead of the workspace + // filesystem. Can be overridden with the DATABRICKS_BUNDLE_MANAGED_STATE + // environment variable. + // + // Experimental: this surface is subject to change without notice. + ManagedState bool `json:"managed_state,omitempty"` + // Deployment section specifies deployment related configuration for bundle Deployment Deployment `json:"deployment,omitempty"` diff --git a/bundle/config/managedstate/managedstate.go b/bundle/config/managedstate/managedstate.go new file mode 100644 index 00000000000..68c34f083a3 --- /dev/null +++ b/bundle/config/managedstate/managedstate.go @@ -0,0 +1,97 @@ +// Package managedstate exposes the configuration surface that gates the +// bundle's use of the Deployment Metadata Service (DMS) for server-managed +// deployment state and locking. +// +// The setting can be controlled via two routes, in priority order: +// 1. The bundle.managed_state field in databricks.yml. +// 2. The DATABRICKS_BUNDLE_MANAGED_STATE environment variable. +// +// When neither route opts in, the bundle falls back to the historical +// workspace-filesystem-based state and lock implementation. Use +// cmd/bundle/utils.ResolveManagedStateSetting to combine the two sources +// with location-aware source attribution. +package managedstate + +import ( + "context" + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/env" +) + +// EnvVar is the environment variable that opts the bundle into managed +// (server-side) deployment state. +const EnvVar = "DATABRICKS_BUNDLE_MANAGED_STATE" + +// Default is used when the user has not opted in via config or env. +const Default = false + +// Setting represents a resolved managed-state setting along with where it +// came from. Source is a human-readable string used in log lines and error +// messages (e.g. "bundle.managed_state setting at databricks.yml:3:5" or +// "DATABRICKS_BUNDLE_MANAGED_STATE environment variable"). +type Setting struct { + Enabled bool + Source string +} + +// FromEnv reads the DATABRICKS_BUNDLE_MANAGED_STATE environment variable. +// +// Accepts the standard strconv.ParseBool spellings (1/0, t/T, true/True/TRUE, +// f/F, false/False/FALSE) and additionally accepts "yes"/"no"/"y"/"n" +// case-insensitively. An unset or empty value returns (false, false, nil) -- +// isSet=false signals that the env var was not set, not that it parsed to +// false. Invalid values return an error. +func FromEnv(ctx context.Context) (value, isSet bool, err error) { + raw := env.Get(ctx, EnvVar) + if raw == "" { + return false, false, nil + } + + switch strings.ToLower(raw) { + case "yes", "y": + return true, true, nil + case "no", "n": + return false, true, nil + } + + parsed, parseErr := strconv.ParseBool(raw) + if parseErr != nil { + return false, true, fmt.Errorf("unexpected setting for %s=%q (expected a boolean value)", EnvVar, raw) + } + return parsed, true, nil +} + +// Resolve combines the bundle.managed_state config field and the +// DATABRICKS_BUNDLE_MANAGED_STATE environment variable into a single +// Setting with source attribution. +// +// Priority: configEnabled (i.e. bundle.managed_state=true in databricks.yml) +// > env var > Default. configValue is the dyn.Value for the bundle root and +// is used only for file/line/column source attribution; pass dyn.InvalidValue +// if a location isn't available. +func Resolve(ctx context.Context, configEnabled bool, configValue dyn.Value) (Setting, error) { + if configEnabled { + source := "bundle.managed_state setting" + v := dyn.GetValue(configValue, "bundle.managed_state") + if locs := v.Locations(); len(locs) > 0 { + loc := locs[0] + source = fmt.Sprintf("bundle.managed_state setting at %s:%d:%d", filepath.ToSlash(loc.File), loc.Line, loc.Column) + } + return Setting{Enabled: true, Source: source}, nil + } + + envValue, isSet, err := FromEnv(ctx) + if err != nil { + return Setting{}, err + } + if isSet { + return Setting{Enabled: envValue, Source: EnvVar + " environment variable"}, nil + } + + return Setting{Enabled: Default}, nil +} diff --git a/bundle/config/managedstate/managedstate_test.go b/bundle/config/managedstate/managedstate_test.go new file mode 100644 index 00000000000..62ef8aa65ed --- /dev/null +++ b/bundle/config/managedstate/managedstate_test.go @@ -0,0 +1,55 @@ +package managedstate + +import ( + "testing" + + "github.com/databricks/cli/libs/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFromEnvNotSet(t *testing.T) { + value, isSet, err := FromEnv(t.Context()) + require.NoError(t, err) + assert.False(t, isSet) + assert.False(t, value) +} + +func TestFromEnvEmpty(t *testing.T) { + ctx := env.Set(t.Context(), EnvVar, "") + value, isSet, err := FromEnv(ctx) + require.NoError(t, err) + assert.False(t, isSet) + assert.False(t, value) +} + +func TestFromEnvTruthy(t *testing.T) { + for _, raw := range []string{"true", "TRUE", "True", "1", "t", "T", "yes", "YES", "Yes", "y", "Y"} { + t.Run(raw, func(t *testing.T) { + ctx := env.Set(t.Context(), EnvVar, raw) + value, isSet, err := FromEnv(ctx) + require.NoError(t, err) + assert.True(t, isSet) + assert.True(t, value) + }) + } +} + +func TestFromEnvFalsy(t *testing.T) { + for _, raw := range []string{"false", "FALSE", "False", "0", "f", "F", "no", "NO", "No", "n", "N"} { + t.Run(raw, func(t *testing.T) { + ctx := env.Set(t.Context(), EnvVar, raw) + value, isSet, err := FromEnv(ctx) + require.NoError(t, err) + assert.True(t, isSet) + assert.False(t, value) + }) + } +} + +func TestFromEnvInvalid(t *testing.T) { + ctx := env.Set(t.Context(), EnvVar, "not-a-bool") + _, _, err := FromEnv(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), EnvVar) +} diff --git a/bundle/docsgen/output/reference.md b/bundle/docsgen/output/reference.md index c88824d79ab..19131fd2422 100644 --- a/bundle/docsgen/output/reference.md +++ b/bundle/docsgen/output/reference.md @@ -1,7 +1,7 @@ --- description: 'Configuration reference for databricks.yml' last_update: - date: 2026-05-21 + date: 2026-05-26 --- @@ -130,6 +130,10 @@ The bundle attributes when deploying to this target, - Map - The Git version control details that are associated with your bundle. For supported attributes see [\_](/dev-tools/bundles/settings.md#git). See [\_](#bundlegit). +- - `managed_state` + - Boolean + - Whether the bundle uses server-side state management via the Deployment Metadata Service. When true, deployment locks and resource state are held by the control plane instead of the workspace filesystem. Takes priority over the `DATABRICKS_BUNDLE_MANAGED_STATE` environment variable. Experimental: this surface is subject to change without notice. + - - `name` - String - The name of the bundle. @@ -2480,6 +2484,10 @@ The bundle attributes when deploying to this target. - Map - The Git version control details that are associated with your bundle. For supported attributes see [\_](/dev-tools/bundles/settings.md#git). See [\_](#targetsnamebundlegit). +- - `managed_state` + - Boolean + - Whether the bundle uses server-side state management via the Deployment Metadata Service. When true, deployment locks and resource state are held by the control plane instead of the workspace filesystem. Takes priority over the `DATABRICKS_BUNDLE_MANAGED_STATE` environment variable. Experimental: this surface is subject to change without notice. + - - `name` - String - The name of the bundle. diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 041ba102ddb..3423f150fd1 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -50,6 +50,9 @@ github.com/databricks/cli/bundle/config.Bundle: The Git version control details that are associated with your bundle. "markdown_description": |- The Git version control details that are associated with your bundle. For supported attributes see [\_](/dev-tools/bundles/settings.md#git). + "managed_state": + "description": |- + Whether the bundle uses server-side state management via the Deployment Metadata Service. When true, deployment locks and resource state are held by the control plane instead of the workspace filesystem. Takes priority over the `DATABRICKS_BUNDLE_MANAGED_STATE` environment variable. Experimental: this surface is subject to change without notice. "name": "description": |- The name of the bundle. diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 2a7f76aac85..94946478520 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -2328,6 +2328,10 @@ "$ref": "#/$defs/github.com/databricks/cli/bundle/config.Git", "markdownDescription": "The Git version control details that are associated with your bundle. For supported attributes see [git](https://docs.databricks.com/dev-tools/bundles/settings.html#git)." }, + "managed_state": { + "description": "Whether the bundle uses server-side state management via the Deployment Metadata Service. When true, deployment locks and resource state are held by the control plane instead of the workspace filesystem. Takes priority over the `DATABRICKS_BUNDLE_MANAGED_STATE` environment variable. Experimental: this surface is subject to change without notice.", + "$ref": "#/$defs/bool" + }, "name": { "description": "The name of the bundle.", "$ref": "#/$defs/string" diff --git a/bundle/schema/jsonschema_for_docs.json b/bundle/schema/jsonschema_for_docs.json index e5caee8b64a..470e310b414 100644 --- a/bundle/schema/jsonschema_for_docs.json +++ b/bundle/schema/jsonschema_for_docs.json @@ -2319,6 +2319,10 @@ "markdownDescription": "The Git version control details that are associated with your bundle. For supported attributes see [git](https://docs.databricks.com/dev-tools/bundles/settings.html#git).", "x-since-version": "v0.229.0" }, + "managed_state": { + "description": "Whether the bundle uses server-side state management via the Deployment Metadata Service. When true, deployment locks and resource state are held by the control plane instead of the workspace filesystem. Takes priority over the `DATABRICKS_BUNDLE_MANAGED_STATE` environment variable. Experimental: this surface is subject to change without notice.", + "$ref": "#/$defs/bool" + }, "name": { "description": "The name of the bundle.", "$ref": "#/$defs/string", diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index 5f43cff6acd..4f3202e27cf 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -9,6 +9,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config/engine" + "github.com/databricks/cli/bundle/config/managedstate" "github.com/databricks/cli/bundle/config/mutator" "github.com/databricks/cli/bundle/config/validate" "github.com/databricks/cli/bundle/deploy/terraform" @@ -374,6 +375,13 @@ func ResolveEngineSetting(ctx context.Context, b *bundle.Bundle) (engine.EngineS return engine.EngineSetting{}, nil } +// ResolveManagedStateSetting determines the effective managed-state setting by +// combining bundle config and env var. +// Priority: bundle.managed_state config > DATABRICKS_BUNDLE_MANAGED_STATE env var > Default. +func ResolveManagedStateSetting(ctx context.Context, b *bundle.Bundle) (managedstate.Setting, error) { + return managedstate.Resolve(ctx, b.Config.Bundle.ManagedState, b.Config.Value()) +} + func rejectDefinitions(ctx context.Context, b *bundle.Bundle) { if b.Config.Definitions != nil { v := dyn.GetValue(b.Config.Value(), "definitions") diff --git a/cmd/bundle/utils/resolve_managed_state_test.go b/cmd/bundle/utils/resolve_managed_state_test.go new file mode 100644 index 00000000000..071bb850a1b --- /dev/null +++ b/cmd/bundle/utils/resolve_managed_state_test.go @@ -0,0 +1,62 @@ +package utils + +import ( + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/managedstate" + "github.com/databricks/cli/libs/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveManagedStateConfigTakesPriority(t *testing.T) { + ctx := env.Set(t.Context(), managedstate.EnvVar, "false") + b := &bundle.Bundle{Config: config.Root{Bundle: config.Bundle{ManagedState: true}}} + result, err := ResolveManagedStateSetting(ctx, b) + require.NoError(t, err) + assert.True(t, result.Enabled) + assert.Contains(t, result.Source, "bundle.managed_state") +} + +func TestResolveManagedStateEnvVarUsedWhenNoConfig(t *testing.T) { + ctx := env.Set(t.Context(), managedstate.EnvVar, "true") + b := &bundle.Bundle{Config: config.Root{}} + result, err := ResolveManagedStateSetting(ctx, b) + require.NoError(t, err) + assert.True(t, result.Enabled) + assert.Contains(t, result.Source, managedstate.EnvVar) +} + +func TestResolveManagedStateNothingSet(t *testing.T) { + b := &bundle.Bundle{Config: config.Root{}} + result, err := ResolveManagedStateSetting(t.Context(), b) + require.NoError(t, err) + assert.False(t, result.Enabled) + assert.Empty(t, result.Source) +} + +func TestResolveManagedStateInvalidEnvVar(t *testing.T) { + ctx := env.Set(t.Context(), managedstate.EnvVar, "not-a-bool") + b := &bundle.Bundle{Config: config.Root{}} + _, err := ResolveManagedStateSetting(ctx, b) + require.Error(t, err) +} + +func TestResolveManagedStateInvalidEnvVarIgnoredWhenConfigSet(t *testing.T) { + ctx := env.Set(t.Context(), managedstate.EnvVar, "not-a-bool") + b := &bundle.Bundle{Config: config.Root{Bundle: config.Bundle{ManagedState: true}}} + result, err := ResolveManagedStateSetting(ctx, b) + require.NoError(t, err) + assert.True(t, result.Enabled) +} + +func TestResolveManagedStateEnvVarFalseExplicit(t *testing.T) { + ctx := env.Set(t.Context(), managedstate.EnvVar, "false") + b := &bundle.Bundle{Config: config.Root{}} + result, err := ResolveManagedStateSetting(ctx, b) + require.NoError(t, err) + assert.False(t, result.Enabled) + assert.Contains(t, result.Source, managedstate.EnvVar) +} diff --git a/libs/testserver/deployment_metadata.go b/libs/testserver/deployment_metadata.go new file mode 100644 index 00000000000..277dd1e112e --- /dev/null +++ b/libs/testserver/deployment_metadata.go @@ -0,0 +1,405 @@ +package testserver + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + sdktime "github.com/databricks/databricks-sdk-go/common/types/time" + sdkbundle "github.com/databricks/databricks-sdk-go/service/bundle" +) + +// deploymentMetadata holds in-memory state for the deployment metadata +// service. One instance lives inside each FakeWorkspace so tests can drive +// CRUD against the DMS routes the same way they drive jobs/apps/etc. +type deploymentMetadata struct { + // deployments keyed by deployment_id. + deployments map[string]sdkbundle.Deployment + + // versions keyed by "deploymentId/versionId". + versions map[string]sdkbundle.Version + + // operations keyed by "deploymentId/versionId/resourceKey". + operations map[string]sdkbundle.Operation + + // resources keyed by "deploymentId/resourceKey". + resources map[string]sdkbundle.Resource + + // lockHolder maps deploymentId -> the full version name that holds the + // lock (e.g. "deployments/{id}/versions/{vid}"). Absent when no lock is + // held. + lockHolder map[string]string + + // lockExpiry maps deploymentId -> when the lock expires; checked against + // time.Now() on every lock-acquiring or lock-respecting call. + lockExpiry map[string]time.Time +} + +func newDeploymentMetadata() *deploymentMetadata { + return &deploymentMetadata{ + deployments: map[string]sdkbundle.Deployment{}, + versions: map[string]sdkbundle.Version{}, + operations: map[string]sdkbundle.Operation{}, + resources: map[string]sdkbundle.Resource{}, + lockHolder: map[string]string{}, + lockExpiry: map[string]time.Time{}, + } +} + +// lockDuration matches the real service's default lease so heartbeat-renewal +// tests have a comfortable margin. +const lockDuration = 2 * time.Minute + +func nowPtr() *sdktime.Time { + return sdktime.New(time.Now().UTC()) +} + +func toSDKTime(t time.Time) *sdktime.Time { + return sdktime.New(t.UTC()) +} + +func badRequest(msg string) Response { + return Response{ + StatusCode: http.StatusBadRequest, + Body: map[string]string{"error_code": "INVALID_PARAMETER_VALUE", "message": msg}, + } +} + +func notFound(msg string) Response { + return Response{ + StatusCode: http.StatusNotFound, + Body: map[string]string{"error_code": "NOT_FOUND", "message": msg}, + } +} + +func aborted(msg string) Response { + return Response{ + StatusCode: http.StatusConflict, + Body: map[string]string{"error_code": "ABORTED", "message": msg}, + } +} + +// DeploymentMetadataCreateDeployment is mounted at +// POST /api/2.0/bundle/deployments. The SDK sends the inner Deployment as the +// body and passes deployment_id as a query parameter. +func (s *FakeWorkspace) DeploymentMetadataCreateDeployment(req Request) Response { + defer s.LockUnlock()() + + deploymentID := req.URL.Query().Get("deployment_id") + if deploymentID == "" { + return badRequest("deployment_id is required") + } + + var body sdkbundle.Deployment + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &body); err != nil { + return badRequest(fmt.Sprintf("invalid request: %s", err)) + } + } + + state := s.deploymentMetadata + if _, exists := state.deployments[deploymentID]; exists { + return Response{ + StatusCode: http.StatusConflict, + Body: map[string]string{ + "error_code": "ALREADY_EXISTS", + "message": fmt.Sprintf("deployment %s already exists", deploymentID), + }, + } + } + + now := nowPtr() + dep := sdkbundle.Deployment{ + Name: "deployments/" + deploymentID, + DisplayName: deploymentID, + TargetName: body.TargetName, + Status: sdkbundle.DeploymentStatusDeploymentStatusActive, + CreatedBy: s.CurrentUser().UserName, + CreateTime: now, + UpdateTime: now, + } + state.deployments[deploymentID] = dep + return Response{Body: dep} +} + +// DeploymentMetadataGetDeployment is mounted at +// GET /api/2.0/bundle/deployments/{deployment_id}. +func (s *FakeWorkspace) DeploymentMetadataGetDeployment(deploymentID string) Response { + defer s.LockUnlock()() + + dep, ok := s.deploymentMetadata.deployments[deploymentID] + if !ok { + return notFound(fmt.Sprintf("deployment %s not found", deploymentID)) + } + return Response{Body: dep} +} + +// DeploymentMetadataDeleteDeployment is mounted at +// DELETE /api/2.0/bundle/deployments/{deployment_id}. +func (s *FakeWorkspace) DeploymentMetadataDeleteDeployment(deploymentID string) Response { + defer s.LockUnlock()() + + state := s.deploymentMetadata + dep, ok := state.deployments[deploymentID] + if !ok { + return notFound(fmt.Sprintf("deployment %s not found", deploymentID)) + } + + now := nowPtr() + dep.Status = sdkbundle.DeploymentStatusDeploymentStatusDeleted + dep.DestroyTime = now + dep.DestroyedBy = s.CurrentUser().UserName + dep.UpdateTime = now + state.deployments[deploymentID] = dep + return Response{Body: dep} +} + +// DeploymentMetadataCreateVersion is mounted at +// POST /api/2.0/bundle/deployments/{deployment_id}/versions. Body = Version, +// query = version_id. Validates monotonic version IDs and enforces the +// deployment-level lock. +func (s *FakeWorkspace) DeploymentMetadataCreateVersion(req Request, deploymentID string) Response { + defer s.LockUnlock()() + + state := s.deploymentMetadata + dep, ok := state.deployments[deploymentID] + if !ok { + return notFound(fmt.Sprintf("deployment %s not found", deploymentID)) + } + + versionID := req.URL.Query().Get("version_id") + if versionID == "" { + return badRequest("version_id is required") + } + + var body sdkbundle.Version + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &body); err != nil { + return badRequest(fmt.Sprintf("invalid request: %s", err)) + } + } + + // Enforce monotonic versions: version_id must equal last_version_id + 1. + expected := "1" + if dep.LastVersionId != "" { + n, err := strconv.ParseInt(dep.LastVersionId, 10, 64) + if err != nil { + return Response{ + StatusCode: http.StatusInternalServerError, + Body: map[string]string{ + "error_code": "INTERNAL_ERROR", + "message": "stored last_version_id is not a valid number: " + dep.LastVersionId, + }, + } + } + expected = strconv.FormatInt(n+1, 10) + } + if versionID != expected { + return aborted(fmt.Sprintf("version_id must be %s (last_version_id + 1), got: %s", + expected, versionID)) + } + + // Enforce lock: if a lock is held and not expired, reject. + now := time.Now().UTC() + if holder, hasLock := state.lockHolder[deploymentID]; hasLock { + if exp, ok := state.lockExpiry[deploymentID]; ok && exp.After(now) { + return aborted(fmt.Sprintf("deployment is locked by %s until %s", + holder, exp.Format(time.RFC3339))) + } + } + + versionKey := deploymentID + "/" + versionID + createTime := toSDKTime(now) + version := sdkbundle.Version{ + Name: fmt.Sprintf("deployments/%s/versions/%s", deploymentID, versionID), + VersionId: versionID, + CreatedBy: s.CurrentUser().UserName, + CreateTime: createTime, + Status: sdkbundle.VersionStatusVersionStatusInProgress, + CliVersion: body.CliVersion, + VersionType: body.VersionType, + TargetName: body.TargetName, + } + state.versions[versionKey] = version + + state.lockHolder[deploymentID] = version.Name + state.lockExpiry[deploymentID] = now.Add(lockDuration) + + dep.LastVersionId = versionID + dep.Status = sdkbundle.DeploymentStatusDeploymentStatusInProgress + dep.UpdateTime = createTime + state.deployments[deploymentID] = dep + + return Response{Body: version} +} + +// DeploymentMetadataGetVersion is mounted at +// GET /api/2.0/bundle/deployments/{deployment_id}/versions/{version_id}. +func (s *FakeWorkspace) DeploymentMetadataGetVersion(deploymentID, versionID string) Response { + defer s.LockUnlock()() + + versionKey := deploymentID + "/" + versionID + version, ok := s.deploymentMetadata.versions[versionKey] + if !ok { + return notFound(fmt.Sprintf("version %s not found", versionKey)) + } + return Response{Body: version} +} + +// DeploymentMetadataHeartbeat is mounted at +// POST /api/2.0/bundle/deployments/{deployment_id}/versions/{version_id}/heartbeat. +// Validates the version is in-progress and holds the lock, then resets the +// lock expiry. +func (s *FakeWorkspace) DeploymentMetadataHeartbeat(_ Request, deploymentID, versionID string) Response { + defer s.LockUnlock()() + + state := s.deploymentMetadata + versionKey := deploymentID + "/" + versionID + version, ok := state.versions[versionKey] + if !ok { + return notFound(fmt.Sprintf("version %s not found", versionKey)) + } + if version.Status != sdkbundle.VersionStatusVersionStatusInProgress { + return aborted("version is no longer in progress") + } + + expectedHolder := fmt.Sprintf("deployments/%s/versions/%s", deploymentID, versionID) + if state.lockHolder[deploymentID] != expectedHolder { + return aborted("lock is not held by this version") + } + + now := time.Now().UTC() + expiry := now.Add(lockDuration) + state.lockExpiry[deploymentID] = expiry + return Response{Body: sdkbundle.HeartbeatResponse{ExpireTime: toSDKTime(expiry)}} +} + +// DeploymentMetadataCompleteVersion is mounted at +// POST /api/2.0/bundle/deployments/{deployment_id}/versions/{version_id}/complete. +// Tests can inject a simulated failure by setting the deployment's target_name +// to "fail-complete": the endpoint returns a 500 so the caller exercises its +// "lock release failed" path. +func (s *FakeWorkspace) DeploymentMetadataCompleteVersion(req Request, deploymentID, versionID string) Response { + defer s.LockUnlock()() + + state := s.deploymentMetadata + + if dep, ok := state.deployments[deploymentID]; ok && dep.TargetName == "fail-complete" { + return Response{ + StatusCode: http.StatusInternalServerError, + Body: map[string]string{ + "error_code": "INTERNAL_ERROR", + "message": "simulated complete version failure", + }, + } + } + + versionKey := deploymentID + "/" + versionID + version, ok := state.versions[versionKey] + if !ok { + return notFound(fmt.Sprintf("version %s not found", versionKey)) + } + if version.Status != sdkbundle.VersionStatusVersionStatusInProgress { + return aborted("version is already completed") + } + + var body sdkbundle.CompleteVersionRequest + if err := json.Unmarshal(req.Body, &body); err != nil { + return badRequest(fmt.Sprintf("invalid request: %s", err)) + } + + now := nowPtr() + version.Status = sdkbundle.VersionStatusVersionStatusCompleted + version.CompleteTime = now + version.CompletionReason = body.CompletionReason + version.CompletedBy = s.CurrentUser().UserName + state.versions[versionKey] = version + + delete(state.lockHolder, deploymentID) + delete(state.lockExpiry, deploymentID) + + if dep, ok := state.deployments[deploymentID]; ok { + switch body.CompletionReason { + case sdkbundle.VersionCompleteVersionCompleteSuccess: + dep.Status = sdkbundle.DeploymentStatusDeploymentStatusActive + case sdkbundle.VersionCompleteVersionCompleteFailure, + sdkbundle.VersionCompleteVersionCompleteForceAbort, + sdkbundle.VersionCompleteVersionCompleteLeaseExpired: + dep.Status = sdkbundle.DeploymentStatusDeploymentStatusFailed + } + dep.UpdateTime = now + state.deployments[deploymentID] = dep + } + + return Response{Body: version} +} + +// DeploymentMetadataCreateOperation is mounted at +// POST /api/2.0/bundle/deployments/{deployment_id}/versions/{version_id}/operations. +// Records the operation and upserts the deployment-level Resource so a +// follow-up ListResources sees the merged view. +func (s *FakeWorkspace) DeploymentMetadataCreateOperation(req Request, deploymentID, versionID string) Response { + defer s.LockUnlock()() + + state := s.deploymentMetadata + + resourceKey := req.URL.Query().Get("resource_key") + if resourceKey == "" { + return badRequest("resource_key is required") + } + + var body sdkbundle.Operation + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &body); err != nil { + return badRequest(fmt.Sprintf("invalid request: %s", err)) + } + } + + now := nowPtr() + opKey := deploymentID + "/" + versionID + "/" + resourceKey + op := sdkbundle.Operation{ + Name: fmt.Sprintf("deployments/%s/versions/%s/operations/%s", deploymentID, versionID, resourceKey), + ResourceKey: resourceKey, + CreateTime: now, + ActionType: body.ActionType, + State: body.State, + ResourceId: body.ResourceId, + Status: body.Status, + ErrorMessage: body.ErrorMessage, + } + state.operations[opKey] = op + + resKey := deploymentID + "/" + resourceKey + state.resources[resKey] = sdkbundle.Resource{ + Name: fmt.Sprintf("deployments/%s/resources/%s", deploymentID, resourceKey), + ResourceKey: resourceKey, + State: body.State, + ResourceId: body.ResourceId, + LastActionType: body.ActionType, + LastVersionId: versionID, + } + + return Response{Body: op} +} + +// DeploymentMetadataListResources is mounted at +// GET /api/2.0/bundle/deployments/{deployment_id}/resources. +func (s *FakeWorkspace) DeploymentMetadataListResources(deploymentID string) Response { + defer s.LockUnlock()() + + state := s.deploymentMetadata + prefix := deploymentID + "/" + var resources []sdkbundle.Resource + for key, r := range state.resources { + if strings.HasPrefix(key, prefix) { + resources = append(resources, r) + } + } + if resources == nil { + resources = []sdkbundle.Resource{} + } + return Response{Body: sdkbundle.ListResourcesResponse{Resources: resources}} +} diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 4870e25e07c..07876994219 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -185,6 +185,10 @@ type FakeWorkspace struct { // clusterVenvs caches Python venvs per existing cluster ID, // matching cloud behavior where libraries are cached on running clusters. clusterVenvs map[string]*clusterEnv + + // deploymentMetadata is the in-memory bundle deployment metadata service + // state. Accessed via the DeploymentMetadata* methods. + deploymentMetadata *deploymentMetadata } func (s *FakeWorkspace) LockUnlock() func() { @@ -314,6 +318,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { postgresImplicitBranches: map[string]bool{}, postgresImplicitEndpoints: map[string]bool{}, clusterVenvs: map[string]*clusterEnv{}, + deploymentMetadata: newDeploymentMetadata(), Alerts: map[string]sql.AlertV2{}, Experiments: map[string]ml.GetExperimentResponse{}, ModelRegistryModels: map[string]ml.Model{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index ed405470f20..0225f47311e 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -993,4 +993,33 @@ func AddDefaultHandlers(server *Server) { }, } }) + + // Bundle deployment metadata service. + server.Handle("POST", "/api/2.0/bundle/deployments", func(req Request) any { + return req.Workspace.DeploymentMetadataCreateDeployment(req) + }) + server.Handle("GET", "/api/2.0/bundle/deployments/{deployment_id}", func(req Request) any { + return req.Workspace.DeploymentMetadataGetDeployment(req.Vars["deployment_id"]) + }) + server.Handle("DELETE", "/api/2.0/bundle/deployments/{deployment_id}", func(req Request) any { + return req.Workspace.DeploymentMetadataDeleteDeployment(req.Vars["deployment_id"]) + }) + server.Handle("POST", "/api/2.0/bundle/deployments/{deployment_id}/versions", func(req Request) any { + return req.Workspace.DeploymentMetadataCreateVersion(req, req.Vars["deployment_id"]) + }) + server.Handle("GET", "/api/2.0/bundle/deployments/{deployment_id}/versions/{version_id}", func(req Request) any { + return req.Workspace.DeploymentMetadataGetVersion(req.Vars["deployment_id"], req.Vars["version_id"]) + }) + server.Handle("POST", "/api/2.0/bundle/deployments/{deployment_id}/versions/{version_id}/heartbeat", func(req Request) any { + return req.Workspace.DeploymentMetadataHeartbeat(req, req.Vars["deployment_id"], req.Vars["version_id"]) + }) + server.Handle("POST", "/api/2.0/bundle/deployments/{deployment_id}/versions/{version_id}/complete", func(req Request) any { + return req.Workspace.DeploymentMetadataCompleteVersion(req, req.Vars["deployment_id"], req.Vars["version_id"]) + }) + server.Handle("POST", "/api/2.0/bundle/deployments/{deployment_id}/versions/{version_id}/operations", func(req Request) any { + return req.Workspace.DeploymentMetadataCreateOperation(req, req.Vars["deployment_id"], req.Vars["version_id"]) + }) + server.Handle("GET", "/api/2.0/bundle/deployments/{deployment_id}/resources", func(req Request) any { + return req.Workspace.DeploymentMetadataListResources(req.Vars["deployment_id"]) + }) }