Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions bundle/configsync/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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 {
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
}
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)
}
55 changes: 55 additions & 0 deletions bundle/configsync/path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package configsync

import (
"fmt"

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

// ensurePathExists ensures all intermediate nodes exist in the path.
// It creates empty maps for missing intermediate map keys.
// For sequences, it creates empty sequences with empty map elements when needed.
// Returns the modified value with all intermediate nodes guaranteed to exist.
func ensurePathExists(v dyn.Value, path dyn.Path) (dyn.Value, error) {
if len(path) == 0 {
return v, nil
}

result := v
for i := 1; i < len(path); i++ {
prefixPath := path[:i]
component := path[i-1]

item, _ := dyn.GetByPath(result, prefixPath)
if !item.IsValid() {
if component.Key() != "" {
key := path[i].Key()
isIndex := key == ""
isKey := key != ""

if i < len(path) && isIndex {
index := path[i].Index()
seq := make([]dyn.Value, index+1)
for j := range seq {
seq[j] = dyn.V(dyn.NewMapping())
}
var err error
result, err = dyn.SetByPath(result, prefixPath, dyn.V(seq))
if err != nil {
return dyn.InvalidValue, fmt.Errorf("failed to create sequence at path %s: %w", prefixPath, err)
}
} else if isKey {
var err error
result, err = dyn.SetByPath(result, prefixPath, dyn.V(dyn.NewMapping()))
if err != nil {
return dyn.InvalidValue, fmt.Errorf("failed to create intermediate path %s: %w", prefixPath, err)
}
}
} else {
return dyn.InvalidValue, fmt.Errorf("sequence index does not exist at path %s", prefixPath)
}
}
}

return result, nil
}
Loading
Loading