package deployer import ( "context" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "github.com/databricks/cli/libs/locker" "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go" "github.com/hashicorp/terraform-exec/tfexec" ) type DeploymentStatus int const ( // Empty plan produced on terraform plan. No changes need to be applied NoChanges DeploymentStatus = iota // Deployment failed. No databricks assets were deployed Failed // Deployment failed/partially succeeded. failed to update remote terraform // state file. // The partially deployed resources are thus untracked and in most cases // will need to be cleaned up manually PartialButUntracked // Deployment failed/partially succeeded. Remote terraform state file is // updated with any partially deployed resources Partial // Deployment succeeded however the remote terraform state was not updated. // The deployed resources are thus untracked and in most cases will need to // be cleaned up manually CompleteButUntracked // Deployment succeeeded with remote terraform state file updated Complete ) // Deployer is a struct to deploy a DAB to a databricks workspace // // Here's a high level description of what a deploy looks like: // // 1. Client compiles the bundle configuration to a terraform HCL config file // // 2. Client tries to acquire a lock on the remote root of the project. // -- If FAIL: print details about current holder of the deployment lock on // remote root and terminate deployment // // 3. Client reads terraform state from remote root // // 4. Client applies the diff in terraform config to the databricks workspace // // 5. Client updates terraform state file in remote root // // 6. Client releases the deploy lock on remote root type Deployer struct { localRoot string remoteRoot string env string locker *locker.Locker wsc *databricks.WorkspaceClient } func Create(ctx context.Context, env, localRoot, remoteRoot string, wsc *databricks.WorkspaceClient) (*Deployer, error) { user, err := wsc.CurrentUser.Me(ctx) if err != nil { return nil, err } newLocker, err := locker.CreateLocker(user.UserName, remoteRoot, wsc) if err != nil { return nil, err } return &Deployer{ localRoot: localRoot, remoteRoot: remoteRoot, env: env, locker: newLocker, wsc: wsc, }, nil } func (b *Deployer) DefaultTerraformRoot() string { return filepath.Join(b.localRoot, ".databricks/bundle", b.env) } func (b *Deployer) tfStateRemotePath() string { // Note: remote paths are scoped to `remoteRoot` through the locker. Also see [Create]. return ".bundle/terraform.tfstate" } func (b *Deployer) tfStateLocalPath() string { return filepath.Join(b.DefaultTerraformRoot(), "terraform.tfstate") } func (d *Deployer) LoadTerraformState(ctx context.Context) error { r, err := d.locker.Read(ctx, d.tfStateRemotePath()) if errors.Is(err, fs.ErrNotExist) { // If remote tf state is absent, use local tf state return nil } if err != nil { return err } defer r.Close() err = os.MkdirAll(d.DefaultTerraformRoot(), os.ModeDir) if err != nil { return err } b, err := io.ReadAll(r) if err != nil { return err } return os.WriteFile(d.tfStateLocalPath(), b, os.ModePerm) } func (b *Deployer) SaveTerraformState(ctx context.Context) error { bytes, err := os.ReadFile(b.tfStateLocalPath()) if err != nil { return err } return b.locker.Write(ctx, b.tfStateRemotePath(), bytes) } func (d *Deployer) Lock(ctx context.Context, isForced bool) error { return d.locker.Lock(ctx, isForced) } func (d *Deployer) Unlock(ctx context.Context) error { return d.locker.Unlock(ctx) } func (d *Deployer) ApplyTerraformConfig(ctx context.Context, configPath, terraformBinaryPath string, isForced bool) (DeploymentStatus, error) { applyErr := d.Lock(ctx, isForced) if applyErr != nil { return Failed, applyErr } defer func() { applyErr = d.Unlock(ctx) if applyErr != nil { log.Errorf(ctx, "failed to unlock deployment mutex: %s", applyErr) } }() applyErr = d.LoadTerraformState(ctx) if applyErr != nil { log.Debugf(ctx, "failed to load terraform state from workspace: %s", applyErr) return Failed, applyErr } tf, applyErr := tfexec.NewTerraform(configPath, terraformBinaryPath) if applyErr != nil { log.Debugf(ctx, "failed to construct terraform object: %s", applyErr) return Failed, applyErr } isPlanNotEmpty, applyErr := tf.Plan(ctx) if applyErr != nil { log.Debugf(ctx, "failed to compute terraform plan: %s", applyErr) return Failed, applyErr } if !isPlanNotEmpty { log.Debugf(ctx, "terraform plan returned a empty diff") return NoChanges, nil } applyErr = tf.Apply(ctx) // upload state even if apply fails to handle partial deployments saveStateErr := d.SaveTerraformState(ctx) if applyErr != nil && saveStateErr != nil { log.Errorf(ctx, "terraform apply failed: %s", applyErr) log.Errorf(ctx, "failed to upload terraform state after partial terraform apply: %s", saveStateErr) return PartialButUntracked, fmt.Errorf("deploymented failed: %s", applyErr) } if applyErr != nil { log.Errorf(ctx, "terraform apply failed: %s", applyErr) return Partial, fmt.Errorf("deploymented failed: %s", applyErr) } if saveStateErr != nil { log.Errorf(ctx, "failed to upload terraform state after completing terraform apply: %s", saveStateErr) return CompleteButUntracked, fmt.Errorf("failed to upload terraform state file: %s", saveStateErr) } return Complete, nil }