Skip to content
Closed
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
35 changes: 34 additions & 1 deletion bundle/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ import (
"github.com/hashicorp/terraform-exec/tfexec"
)

const internalFolder = ".internal"
const (
internalFolder = ".internal"
cacheFolder = ".cache"
)

// Filename where resources are stored for DATABRICKS_CLI_DEPLOYMENT=direct
const resourcesFilename = "resources.json"
Expand Down Expand Up @@ -273,6 +276,36 @@ func (b *Bundle) InternalDir(ctx context.Context) (string, error) {
return dir, nil
}

// BundleLevelCacheDir is used to cache components needed for the bundle that are target-independent
func (b *Bundle) BundleLevelCacheDir(ctx context.Context, cacheComponentName string) (string, error) {
cacheDirName, exists := env.TempDir(ctx)
if !exists || cacheDirName == "" {
cacheDirName = filepath.Join(
// Anchor at bundle root directory.
b.BundleRootPath,
// Static cache directory.
".databricks",
)
}

// Fixed components of the result path.
parts := []string{
cacheDirName,
cacheFolder,
cacheComponentName,
}

// Make directory if it doesn't exist yet.
dir := filepath.Join(parts...)
err := os.MkdirAll(dir, 0o700)
if err != nil {
return "", err
}

libsync.WriteGitIgnore(ctx, b.BundleRootPath)
return dir, nil
}

// GetSyncIncludePatterns returns a list of user defined includes
// And also adds InternalDir folder to include list for sync command
// so this folder is always synced
Expand Down
119 changes: 119 additions & 0 deletions bundle/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package bundle

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
)

// Cache provides an abstract interface for caching content to local disk.
// Implementations should handle storing and retrieving cached components
// using fingerprints for cache invalidation.
type Cache interface {
// Read retrieves cached content for the given fingerprint.
// Returns the cached data and true if found, or nil and false if not found or expired.
Read(ctx context.Context, fingerprint string) ([]byte, bool)

// Store saves content to the cache with the given fingerprint.
// Returns an error if the cache operation fails.
Store(ctx context.Context, fingerprint string, content []byte) error

// Clear removes all cached content from the cache directory.
Clear(ctx context.Context) error

// ClearFingerprint removes cached content for a specific fingerprint.
ClearFingerprint(ctx context.Context, fingerprint string) error
}

// FileCache implements the Cache interface using the local filesystem.
type FileCache struct {
cachePath string
}

// NewFileCache creates a new filesystem-based cache at the specified path.
func NewFileCache(cachePath string) *FileCache {
return &FileCache{
cachePath: cachePath,
}
}

// Read retrieves cached content for the given fingerprint.
func (fc *FileCache) Read(ctx context.Context, fingerprint string) ([]byte, bool) {
filePath := fc.getFilePath(fingerprint)
data, err := os.ReadFile(filePath)
if err != nil {
return nil, false
}

return data, true
}

// Store saves content to the cache with the given fingerprint.
func (fc *FileCache) Store(ctx context.Context, fingerprint string, content []byte) error {
filePath := fc.getFilePath(fingerprint)
if err := os.WriteFile(filePath, content, 0o600); err != nil {
return fmt.Errorf("failed to write cache file: %w", err)
}

return nil
}

// Clear removes all cached content from the cache directory.
func (fc *FileCache) Clear(ctx context.Context) error {
if _, err := os.Stat(fc.cachePath); os.IsNotExist(err) {
return nil
}

return os.RemoveAll(fc.cachePath)
}

// ClearFingerprint removes cached content for a specific fingerprint.
func (fc *FileCache) ClearFingerprint(ctx context.Context, fingerprint string) error {
filePath := fc.getFilePath(fingerprint)
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove cache file: %w", err)
}
return nil
}

// getFilePath returns the full file path for a given fingerprint.
func (fc *FileCache) getFilePath(fingerprint string) string {
return filepath.Join(fc.cachePath, fingerprint+".cache")
}

// GenerateFingerprint creates a SHA256 fingerprint from the provided data.
// This is a utility function for creating consistent fingerprints.
func GenerateFingerprint(data ...any) (string, error) {
hasher := sha256.New()

for _, item := range data {
var bytes []byte
var err error

switch v := item.(type) {
case string:
bytes = []byte(v)
case []byte:
bytes = v
case io.Reader:
bytes, err = io.ReadAll(v)
if err != nil {
return "", fmt.Errorf("failed to read data for fingerprint: %w", err)
}
default:
bytes, err = json.Marshal(v)
if err != nil {
return "", fmt.Errorf("failed to marshal data for fingerprint: %w", err)
}
}

hasher.Write(bytes)
}

return hex.EncodeToString(hasher.Sum(nil)), nil
}
75 changes: 75 additions & 0 deletions bundle/config/mutator/populate_current_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package mutator

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"unsafe"
Expand All @@ -17,15 +20,43 @@ import (
"github.com/databricks/databricks-sdk-go/service/iam"
)

// cacheHitError is returned when a cached user is found to skip HTTP request
type cacheHitError struct {
user *iam.User
}

func (e *cacheHitError) Error() string {
return "user found in cache"
}

type populateCurrentUser struct {
lastKnownAuthorizationHeader string
cache bundle.Cache
}

// PopulateCurrentUser sets the `current_user` property on the workspace.
func PopulateCurrentUser() bundle.Mutator {
return &populateCurrentUser{}
}

// initializeCache sets up the cache for authorization headers if not already initialized
func (m *populateCurrentUser) initializeCache(ctx context.Context, b *bundle.Bundle) error {
if m.cache != nil {
return nil
}

cacheDir, err := b.BundleLevelCacheDir(ctx, "auth")
if err != nil {
return err
}

m.cache = bundle.NewFileCache(cacheDir)

fmt.Printf("New cache dir initialized: %s\n", cacheDir)

return nil
}

func (m *populateCurrentUser) Name() string {
return "PopulateCurrentUser"
}
Expand All @@ -35,8 +66,14 @@ func (m *populateCurrentUser) Apply(ctx context.Context, b *bundle.Bundle) diag.
return nil
}

// Initialize cache for authorization headers
if err := m.initializeCache(ctx, b); err != nil {
return diag.FromErr(err)
}

w := b.WorkspaceClient()
d := getDatabricksClient(w)

me, err := m.getCurrentUserWithAuthTracking(ctx, d)
if err != nil {
return diag.FromErr(err)
Expand Down Expand Up @@ -68,13 +105,51 @@ func (m *populateCurrentUser) getCurrentUserWithAuthTracking(ctx context.Context
continue
}
for _, value := range values {
if m.cache != nil {
fingerprint, err := bundle.GenerateFingerprint("auth_header", value)
if err != nil {
panic(err)
}
cachedUserBytes, isCacheHit := m.cache.Read(ctx, fingerprint)
if isCacheHit {
var cachedUser iam.User
if err := json.Unmarshal(cachedUserBytes, &cachedUser); err == nil {
return &cacheHitError{user: &cachedUser}
}
}
}
m.lastKnownAuthorizationHeader = value
}
}
return nil
}

err := client.Do(ctx, http.MethodGet, path, headers, nil, nil, &user, headerInspector)

// Check if we got a cache hit error
var cacheHit *cacheHitError
if err != nil && errors.As(err, &cacheHit) {
return cacheHit.user, nil
}

// Store authorization header in cache
if m.cache != nil && m.lastKnownAuthorizationHeader != "" {
fingerprint, err := bundle.GenerateFingerprint("auth_header", m.lastKnownAuthorizationHeader)
if err != nil {
panic(err)
}

userBytes, err := json.Marshal(&user)
if err != nil {
return nil, err
}

err = m.cache.Store(ctx, fingerprint, userBytes)
if err != nil {
fmt.Printf("cache store error: %s\n", err)
}
}

return &user, err
}

Expand Down
Loading