Skip to content
47 changes: 47 additions & 0 deletions bundle/configsync/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package configsync

import (
"context"
"fmt"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/deployplan"
"github.com/databricks/cli/bundle/direct"
"github.com/databricks/cli/libs/log"
)

// DetectChanges compares current remote state with the last deployed state
// and returns a map of resource changes.
func DetectChanges(ctx context.Context, b *bundle.Bundle) (map[string]deployplan.Changes, error) {
changes := make(map[string]deployplan.Changes)

deployBundle := &direct.DeploymentBundle{}
// TODO: for Terraform engine we should read the state file, converted to direct state format, it should be created during deployment
_, statePath := b.StateFilenameDirect(ctx)

plan, err := deployBundle.CalculatePlan(ctx, b.WorkspaceClient(), &b.Config, statePath)
if err != nil {
return nil, fmt.Errorf("failed to calculate plan: %w", err)
}

for resourceKey, entry := range plan.Plan {
resourceChanges := make(deployplan.Changes)

if entry.Changes != nil {
for path, changeDesc := range entry.Changes {
// TODO: distinguish action Skip between actual server-side defaults and remote-side changes
if changeDesc.Remote != nil && changeDesc.Action != deployplan.Skip {
resourceChanges[path] = changeDesc
}
}
}

if len(resourceChanges) != 0 {
changes[resourceKey] = resourceChanges
}

log.Debugf(ctx, "Resource %s has %d changes", resourceKey, len(resourceChanges))
}

return changes, nil
}
116 changes: 116 additions & 0 deletions bundle/configsync/dyn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package configsync

import (
"context"
"errors"
"fmt"
"strconv"
"strings"

"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/structs/structpath"
)

// structpathToDynPath converts a structpath string to a dyn.Path
// Example: "tasks[0].timeout_seconds" -> Path{Key("tasks"), Index(0), Key("timeout_seconds")}
// Also supports "tasks[task_key='my_task']" syntax for array element selection by field value
func structpathToDynPath(_ context.Context, pathStr string, baseValue dyn.Value) (dyn.Path, error) {
node, err := structpath.Parse(pathStr)
if err != nil {
return nil, fmt.Errorf("failed to parse path %s: %w", pathStr, err)
}

nodes := node.AsSlice()

var dynPath dyn.Path
currentValue := baseValue

for _, n := range nodes {
if key, ok := n.StringKey(); ok {
dynPath = append(dynPath, dyn.Key(key))

if currentValue.IsValid() {
currentValue, _ = dyn.GetByPath(currentValue, dyn.Path{dyn.Key(key)})
}
continue
}

if idx, ok := n.Index(); ok {
dynPath = append(dynPath, dyn.Index(idx))

if currentValue.IsValid() {
currentValue, _ = dyn.GetByPath(currentValue, dyn.Path{dyn.Index(idx)})
}
continue
}

// Check for key-value selector: [key='value']
if key, value, ok := n.KeyValue(); ok {
if !currentValue.IsValid() || currentValue.Kind() != dyn.KindSequence {
return nil, fmt.Errorf("cannot apply [key='value'] selector to non-array value at path %s", dynPath.String())
}

seq, _ := currentValue.AsSequence()
foundIndex := -1

for i, elem := range seq {
keyValue, err := dyn.GetByPath(elem, dyn.Path{dyn.Key(key)})
if err != nil {
continue
}

if keyValue.Kind() == dyn.KindString && keyValue.MustString() == value {
foundIndex = i
break
}
}

if foundIndex == -1 {
return nil, fmt.Errorf("no array element found with %s='%s' at path %s", key, value, dynPath.String())
}

dynPath = append(dynPath, dyn.Index(foundIndex))
currentValue = seq[foundIndex]
continue
}

if n.DotStar() || n.BracketStar() {
return nil, errors.New("wildcard patterns are not supported in field paths")
}
}

return dynPath, nil
}

// dynPathToJSONPointer converts a dyn.Path to RFC 6902 JSON Pointer format
// Example: [Key("resources"), Key("jobs"), Key("my_job")] -> "/resources/jobs/my_job"
// Example: [Key("tasks"), Index(1), Key("timeout")] -> "/tasks/1/timeout"
func dynPathToJSONPointer(path dyn.Path) string {
if len(path) == 0 {
return ""
}

var builder strings.Builder
for _, component := range path {
builder.WriteString("/")

// Handle Key components
if key := component.Key(); key != "" {
// Escape special characters per RFC 6902
// ~ must be escaped as ~0
// / must be escaped as ~1
escaped := strings.ReplaceAll(key, "~", "~0")
escaped = strings.ReplaceAll(escaped, "/", "~1")
builder.WriteString(escaped)
continue
}

// Handle Index components
if idx := component.Index(); idx >= 0 {
builder.WriteString(strconv.Itoa(idx))
continue
}
}

return builder.String()
}
30 changes: 30 additions & 0 deletions bundle/configsync/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package configsync

import (
"fmt"
"strings"

"github.com/databricks/cli/bundle/deployplan"
)

// FormatTextOutput formats the config changes as human-readable text. Useful for debugging
func FormatTextOutput(changes map[string]deployplan.Changes) string {
var output strings.Builder

if len(changes) == 0 {
output.WriteString("No changes detected.\n")
return output.String()
}

output.WriteString(fmt.Sprintf("Detected changes in %d resource(s):\n\n", len(changes)))

for resourceKey, resourceChanges := range changes {
output.WriteString(fmt.Sprintf("Resource: %s\n", resourceKey))

for path, changeDesc := range resourceChanges {
output.WriteString(fmt.Sprintf(" %s: %s\n", path, changeDesc.Action))
}
}

return output.String()
}
39 changes: 39 additions & 0 deletions bundle/configsync/output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package configsync

import (
"context"
"os"
"path/filepath"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/deployplan"
)

// FileChange represents a change to a bundle configuration file
type FileChange struct {
Path string `json:"path"`
OriginalContent string `json:"originalContent"`
ModifiedContent string `json:"modifiedContent"`
}

// DiffOutput represents the complete output of the config-remote-sync command
type DiffOutput struct {
Files []FileChange `json:"files"`
Changes map[string]deployplan.Changes `json:"changes"`
}

// SaveFiles writes all file changes to disk.
func SaveFiles(ctx context.Context, b *bundle.Bundle, files []FileChange) error {
Copy link
Contributor Author

@ilyakuz-db ilyakuz-db Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Does it make sense to use yamlsaver here? Not sure what the benefits would be

for _, file := range files {
err := os.MkdirAll(filepath.Dir(file.Path), 0o755)
if err != nil {
return err
}

err = os.WriteFile(file.Path, []byte(file.ModifiedContent), 0o644)
if err != nil {
return err
}
}
return nil
}
89 changes: 89 additions & 0 deletions bundle/configsync/output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package configsync

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/databricks/cli/bundle"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSaveFiles_Success(t *testing.T) {
ctx := context.Background()

tmpDir := t.TempDir()

yamlPath := filepath.Join(tmpDir, "subdir", "databricks.yml")
modifiedContent := `resources:
jobs:
test_job:
name: "Updated Job"
timeout_seconds: 7200
`

files := []FileChange{
{
Path: yamlPath,
OriginalContent: "original content",
ModifiedContent: modifiedContent,
},
}

err := SaveFiles(ctx, &bundle.Bundle{}, files)
require.NoError(t, err)

_, err = os.Stat(yamlPath)
require.NoError(t, err)

content, err := os.ReadFile(yamlPath)
require.NoError(t, err)
assert.Equal(t, modifiedContent, string(content))

_, err = os.Stat(filepath.Dir(yamlPath))
require.NoError(t, err)
}

func TestSaveFiles_MultipleFiles(t *testing.T) {
ctx := context.Background()

tmpDir := t.TempDir()

file1Path := filepath.Join(tmpDir, "file1.yml")
file2Path := filepath.Join(tmpDir, "subdir", "file2.yml")
content1 := "content for file 1"
content2 := "content for file 2"

files := []FileChange{
{
Path: file1Path,
OriginalContent: "original 1",
ModifiedContent: content1,
},
{
Path: file2Path,
OriginalContent: "original 2",
ModifiedContent: content2,
},
}

err := SaveFiles(ctx, &bundle.Bundle{}, files)
require.NoError(t, err)

content, err := os.ReadFile(file1Path)
require.NoError(t, err)
assert.Equal(t, content1, string(content))

content, err = os.ReadFile(file2Path)
require.NoError(t, err)
assert.Equal(t, content2, string(content))
}

func TestSaveFiles_EmptyList(t *testing.T) {
ctx := context.Background()

err := SaveFiles(ctx, &bundle.Bundle{}, []FileChange{})
require.NoError(t, err)
}
Loading
Loading