Skip to content

Commit e3ffa06

Browse files
denikpietern
andauthored
direct: new type-safe way of defining resources (#3532)
## Changes - All resource methods now use concrete types related to this resource, e.g. for job we define DoCreate(ctx, config *jobs.JobSettings). - Resources no longer store config on the instance, it will be passed to methods that need it. - All resource-related code is now configured via resource methods in one file (no longer have separate Settings struct). Each tnresources/_resourcename_.go is self contained with exception of entry point registration in all.go. - The resource implementation is now a singleton, it does not have state related to a particular resource but instead it's a stateless component used for all resources. One consequence is that we cannot store waiters there but need to reconstruct it ourselves. That is okay, because we need to make "ready" part of the persisted state anyway and be able to wait on the resource that was created by previous process. - Internal renames: BundleDeployer -> DeploymentBundle, Deployer -> DeploymentUnit. - DeploymentUnits are now cached between plan and apply, allowing for remote state cache to be propagated between these phases (needed in #3531). - New package libs/calladapt to facilitate validating and calling such methods against interface. ## Why This makes resource implementation type-safe, making it easier to implement manually today or via LLM or generator in the future. Especially useful when there are more types / structs involved, as in remote state support #3531 Also, you get initial feedback quickly, by doing "go test ./bundle/terranova/tnresources'. That type checks all the interfaces and runs your resource against testserver. ## Tests There are a new set of unit tests that do a bunch of type checks across methods, e.g. DoCreate and DoUpdate must accept the same config type. There are also unit tests that run a few of resource methods against testserver. --------- Co-authored-by: Pieter Noordhuis <[email protected]>
1 parent e6f94a3 commit e3ffa06

34 files changed

+1767
-777
lines changed

.golangci.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ linters:
6969
- path-except: bundle/terranova/tnresources
7070
linters:
7171
- exhaustruct
72+
- path: bundle/terranova/tnresources/all_test.go
73+
linters:
74+
- exhaustruct
7275
issues:
7376
max-issues-per-linter: 1000
7477
max-same-issues: 1000

bundle/bundle.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,8 @@ type Bundle struct {
129129
// If true, the plan is empty and applying it will not do anything
130130
TerraformPlanIsEmpty bool
131131

132-
// (direct only) graph of dependencies between resources
133-
BundleDeployer terranova.BundleDeployer
132+
// (direct only) deployment implementation and state
133+
DeploymentBundle terranova.DeploymentBundle
134134

135135
// if true, we skip approval checks for deploy, destroy resources and delete
136136
// files
@@ -351,7 +351,7 @@ func (b *Bundle) OpenStateFile(ctx context.Context) error {
351351
return err
352352
}
353353

354-
err = b.BundleDeployer.OpenStateFile(statePath)
354+
err = b.DeploymentBundle.OpenStateFile(statePath)
355355
if err != nil {
356356
return fmt.Errorf("failed to open/create state file at %s: %s", statePath, err)
357357
}

bundle/phases/deploy.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ func getActions(ctx context.Context, b *bundle.Bundle) ([]deployplan.Action, err
3131
if err != nil {
3232
return nil, err
3333
}
34-
err = b.BundleDeployer.CalculatePlanForDeploy(ctx, b.WorkspaceClient(), &b.Config)
34+
err = b.DeploymentBundle.CalculatePlanForDeploy(ctx, b.WorkspaceClient(), &b.Config)
3535
if err != nil {
3636
return nil, err
3737
}
38-
return b.BundleDeployer.GetActions(ctx), nil
38+
return b.DeploymentBundle.GetActions(ctx), nil
3939
} else {
4040
tf := b.Terraform
4141
if tf == nil {
@@ -118,7 +118,7 @@ func deployCore(ctx context.Context, b *bundle.Bundle) {
118118
cmdio.LogString(ctx, "Deploying resources...")
119119

120120
if b.DirectDeployment {
121-
b.BundleDeployer.Apply(ctx, b.WorkspaceClient(), &b.Config)
121+
b.DeploymentBundle.Apply(ctx, b.WorkspaceClient(), &b.Config)
122122
} else {
123123
bundle.ApplyContext(ctx, b, terraform.Apply())
124124
}

bundle/phases/destroy.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ func getDeleteActions(ctx context.Context, b *bundle.Bundle) ([]deployplan.Actio
3636
if err != nil {
3737
return nil, err
3838
}
39-
err = b.BundleDeployer.CalculatePlanForDestroy(ctx)
39+
err = b.DeploymentBundle.CalculatePlanForDestroy(ctx, b.WorkspaceClient())
4040
if err != nil {
4141
return nil, err
4242
}
43-
return b.BundleDeployer.GetActions(ctx), nil
43+
return b.DeploymentBundle.GetActions(ctx), nil
4444
}
4545

4646
tf := b.Terraform
@@ -119,7 +119,7 @@ func approvalForDestroy(ctx context.Context, b *bundle.Bundle) (bool, error) {
119119

120120
func destroyCore(ctx context.Context, b *bundle.Bundle) {
121121
if b.DirectDeployment {
122-
b.BundleDeployer.Apply(ctx, b.WorkspaceClient(), &b.Config)
122+
b.DeploymentBundle.Apply(ctx, b.WorkspaceClient(), &b.Config)
123123
} else {
124124
// Core destructive mutators for destroy. These require informed user consent.
125125
bundle.ApplyContext(ctx, b, terraform.Apply())

bundle/statemgmt/state_load.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func (l *load) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
4040
if err != nil {
4141
return diag.FromErr(err)
4242
}
43-
state = b.BundleDeployer.StateDB.ExportState(ctx)
43+
state = b.DeploymentBundle.StateDB.ExportState(ctx)
4444
} else {
4545
tf := b.Terraform
4646
if tf == nil {

bundle/terranova/apply.go

Lines changed: 33 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -11,49 +11,38 @@ import (
1111
"github.com/databricks/cli/bundle/deployplan"
1212
"github.com/databricks/cli/bundle/terranova/tnstate"
1313
"github.com/databricks/cli/libs/log"
14-
"github.com/databricks/databricks-sdk-go"
1514
)
1615

17-
type Deployer struct {
18-
client *databricks.WorkspaceClient
19-
db *tnstate.TerranovaState
20-
group string
21-
resourceName string
22-
settings ResourceSettings
23-
}
24-
25-
func (d *Deployer) Destroy(ctx context.Context) error {
26-
entry, hasEntry := d.db.GetResourceEntry(d.group, d.resourceName)
16+
func (d *DeploymentUnit) Destroy(ctx context.Context, db *tnstate.TerranovaState) error {
17+
entry, hasEntry := db.GetResourceEntry(d.Group, d.Key)
2718
if !hasEntry {
28-
log.Infof(ctx, "Cannot delete %s.%s: missing from state", d.group, d.resourceName)
19+
log.Infof(ctx, "Cannot delete %s.%s: missing from state", d.Group, d.Key)
2920
return nil
3021
}
3122

3223
if entry.ID == "" {
3324
return errors.New("invalid state: empty id")
3425
}
3526

36-
err := d.Delete(ctx, entry.ID)
27+
err := d.Delete(ctx, db, entry.ID)
3728
if err != nil {
3829
return err
3930
}
4031

4132
return nil
4233
}
4334

44-
func (d *Deployer) Deploy(ctx context.Context, inputConfig any, actionType deployplan.ActionType) error {
45-
resource, _, err := New(d.client, d.group, d.resourceName, inputConfig)
35+
func (d *DeploymentUnit) Deploy(ctx context.Context, db *tnstate.TerranovaState, inputConfig any, actionType deployplan.ActionType) error {
36+
config, err := d.Adapter.PrepareConfig(inputConfig)
4637
if err != nil {
4738
return err
4839
}
4940

50-
config := resource.Config()
51-
5241
if actionType == deployplan.ActionTypeCreate {
53-
return d.Create(ctx, resource, config)
42+
return d.Create(ctx, db, config)
5443
}
5544

56-
entry, hasEntry := d.db.GetResourceEntry(d.group, d.resourceName)
45+
entry, hasEntry := db.GetResourceEntry(d.Group, d.Key)
5746
if !hasEntry {
5847
return errors.New("state entry not found")
5948
}
@@ -65,126 +54,104 @@ func (d *Deployer) Deploy(ctx context.Context, inputConfig any, actionType deplo
6554

6655
switch actionType {
6756
case deployplan.ActionTypeRecreate:
68-
return d.Recreate(ctx, resource, oldID, config)
57+
return d.Recreate(ctx, db, oldID, config)
6958
case deployplan.ActionTypeUpdate:
70-
return d.Update(ctx, resource, oldID, config)
59+
return d.Update(ctx, db, oldID, config)
7160
case deployplan.ActionTypeUpdateWithID:
72-
updater, hasUpdater := resource.(IResourceUpdatesID)
73-
if !hasUpdater {
74-
return errors.New("internal error: plan is update_with_id but resource does not implement UpdateWithID")
75-
}
76-
return d.UpdateWithID(ctx, resource, updater, oldID, config)
61+
return d.UpdateWithID(ctx, db, oldID, config)
7762
default:
7863
return fmt.Errorf("internal error: unexpected actionType: %#v", actionType)
7964
}
8065
}
8166

82-
func (d *Deployer) Create(ctx context.Context, resource IResource, config any) error {
83-
newID, err := resource.DoCreate(ctx)
67+
func (d *DeploymentUnit) Create(ctx context.Context, db *tnstate.TerranovaState, config any) error {
68+
newID, err := d.Adapter.DoCreate(ctx, config)
8469
if err != nil {
8570
// No need to prefix error, there is no ambiguity (only one operation - DoCreate) and no additional context (like id)
8671
return err
8772
}
8873

89-
log.Infof(ctx, "Created %s.%s id=%#v", d.group, d.resourceName, newID)
74+
log.Infof(ctx, "Created %s.%s id=%#v", d.Group, d.Key, newID)
9075

91-
err = d.db.SaveState(d.group, d.resourceName, newID, config)
76+
err = db.SaveState(d.Group, d.Key, newID, config)
9277
if err != nil {
9378
return fmt.Errorf("saving state after creating id=%s: %w", newID, err)
9479
}
9580

96-
err = resource.WaitAfterCreate(ctx)
81+
err = d.Adapter.WaitAfterCreate(ctx, config)
9782
if err != nil {
9883
return fmt.Errorf("waiting after creating id=%s: %w", newID, err)
9984
}
10085

10186
return nil
10287
}
10388

104-
func (d *Deployer) Recreate(ctx context.Context, resource IResource, oldID string, config any) error {
105-
err := DeleteResource(ctx, d.client, d.group, oldID)
89+
func (d *DeploymentUnit) Recreate(ctx context.Context, db *tnstate.TerranovaState, oldID string, config any) error {
90+
err := d.Adapter.DoDelete(ctx, oldID)
10691
if err != nil {
10792
return fmt.Errorf("deleting old id=%s: %w", oldID, err)
10893
}
10994

110-
err = d.db.SaveState(d.group, d.resourceName, "", nil)
95+
err = db.SaveState(d.Group, d.Key, "", nil)
11196
if err != nil {
11297
return fmt.Errorf("deleting state: %w", err)
11398
}
11499

115-
newID, err := resource.DoCreate(ctx)
116-
if err != nil {
117-
return fmt.Errorf("re-creating: %w", err)
118-
}
119-
120-
// TODO: This should be at notice level (info < notice < warn) and it should be visible by default,
121-
// but to match terraform output today, we hide it (and also we don't have notice level)
122-
log.Infof(ctx, "Recreated %s.%s id=%#v (previously %#v)", d.group, d.resourceName, newID, oldID)
123-
err = d.db.SaveState(d.group, d.resourceName, newID, config)
124-
if err != nil {
125-
return fmt.Errorf("saving state for id=%s: %w", newID, err)
126-
}
127-
128-
err = resource.WaitAfterCreate(ctx)
129-
if err != nil {
130-
return fmt.Errorf("waiting after re-creating id=%s: %w", newID, err)
131-
}
132-
133-
return nil
100+
return d.Create(ctx, db, config)
134101
}
135102

136-
func (d *Deployer) Update(ctx context.Context, resource IResource, id string, config any) error {
137-
err := resource.DoUpdate(ctx, id)
103+
func (d *DeploymentUnit) Update(ctx context.Context, db *tnstate.TerranovaState, id string, config any) error {
104+
err := d.Adapter.DoUpdate(ctx, id, config)
138105
if err != nil {
139106
return fmt.Errorf("updating id=%s: %w", id, err)
140107
}
141108

142-
err = d.db.SaveState(d.group, d.resourceName, id, config)
109+
err = db.SaveState(d.Group, d.Key, id, config)
143110
if err != nil {
144111
return fmt.Errorf("saving state id=%s: %w", id, err)
145112
}
146113

147-
err = resource.WaitAfterUpdate(ctx)
114+
err = d.Adapter.WaitAfterUpdate(ctx, config)
148115
if err != nil {
149116
return fmt.Errorf("waiting after updating id=%s: %w", id, err)
150117
}
151118

152119
return nil
153120
}
154121

155-
func (d *Deployer) UpdateWithID(ctx context.Context, resource IResource, updater IResourceUpdatesID, oldID string, config any) error {
156-
newID, err := updater.DoUpdateWithID(ctx, oldID)
122+
func (d *DeploymentUnit) UpdateWithID(ctx context.Context, db *tnstate.TerranovaState, oldID string, config any) error {
123+
newID, err := d.Adapter.DoUpdateWithID(ctx, oldID, config)
157124
if err != nil {
158125
return fmt.Errorf("updating id=%s: %w", oldID, err)
159126
}
160127

161128
if oldID != newID {
162-
log.Infof(ctx, "Updated %s.%s id=%#v (previously %#v)", d.group, d.resourceName, newID, oldID)
129+
log.Infof(ctx, "Updated %s.%s id=%#v (previously %#v)", d.Group, d.Key, newID, oldID)
163130
} else {
164-
log.Infof(ctx, "Updated %s.%s id=%#v", d.group, d.resourceName, newID)
131+
log.Infof(ctx, "Updated %s.%s id=%#v", d.Group, d.Key, newID)
165132
}
166133

167-
err = d.db.SaveState(d.group, d.resourceName, newID, config)
134+
err = db.SaveState(d.Group, d.Key, newID, config)
168135
if err != nil {
169136
return fmt.Errorf("saving state id=%s: %w", oldID, err)
170137
}
171138

172-
err = resource.WaitAfterUpdate(ctx)
139+
err = d.Adapter.WaitAfterUpdate(ctx, config)
173140
if err != nil {
174141
return fmt.Errorf("waiting after updating id=%s: %w", newID, err)
175142
}
176143

177144
return nil
178145
}
179146

180-
func (d *Deployer) Delete(ctx context.Context, oldID string) error {
147+
func (d *DeploymentUnit) Delete(ctx context.Context, db *tnstate.TerranovaState, oldID string) error {
181148
// TODO: recognize 404 and 403 as "deleted" and proceed to removing state
182-
err := DeleteResource(ctx, d.client, d.group, oldID)
149+
err := d.Adapter.DoDelete(ctx, oldID)
183150
if err != nil {
184151
return fmt.Errorf("deleting id=%s: %w", oldID, err)
185152
}
186153

187-
err = d.db.DeleteState(d.group, d.resourceName)
154+
err = db.DeleteState(d.Group, d.Key)
188155
if err != nil {
189156
return fmt.Errorf("deleting state id=%s: %w", oldID, err)
190157
}

bundle/terranova/bundle_apply.go

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,51 +10,41 @@ import (
1010
"github.com/databricks/databricks-sdk-go"
1111
)
1212

13-
func (b *BundleDeployer) Apply(ctx context.Context, client *databricks.WorkspaceClient, configRoot *config.Root) {
13+
func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.WorkspaceClient, configRoot *config.Root) {
1414
if b.Graph == nil {
1515
panic("Planning is not done")
1616
}
1717

18-
if len(b.PlannedActions) == 0 {
18+
if len(b.DeploymentUnits) == 0 {
1919
// Avoid creating state file if nothing to deploy
2020
return
2121
}
2222

2323
b.StateDB.AssertOpened()
2424

2525
b.Graph.Run(defaultParallelism, func(node deployplan.ResourceNode, failedDependency *deployplan.ResourceNode) bool {
26-
actionType := b.PlannedActions[node]
27-
28-
errorPrefix := fmt.Sprintf("cannot %s %s.%s", actionType.String(), node.Group, node.Key)
26+
d, exists := b.DeploymentUnits[node]
27+
if !exists {
28+
// Resource with actionType == noop are not added to DeploymentUnits.
29+
// All references to it must have been resolved during planning.
30+
return true
31+
}
32+
errorPrefix := fmt.Sprintf("cannot %s %s.%s", d.ActionType.String(), node.Group, node.Key)
2933

3034
// If a dependency failed, report and skip execution for this node by returning false
3135
if failedDependency != nil {
3236
logdiag.LogError(ctx, fmt.Errorf("%s: dependency failed: %s", errorPrefix, failedDependency.String()))
3337
return false
3438
}
3539

36-
settings, ok := SupportedResources[node.Group]
37-
if !ok {
38-
// Unexpected, this should be filtered at plan.
39-
return false
40-
}
41-
4240
// The way plan currently works, is that it does not add resources with Noop action, turning them into Unset.
4341
// So we skip both, although at this point we will not see Noop here.
44-
if actionType == deployplan.ActionTypeUnset || actionType == deployplan.ActionTypeNoop {
42+
if d.ActionType == deployplan.ActionTypeUnset || d.ActionType == deployplan.ActionTypeNoop {
4543
return true
4644
}
4745

48-
d := Deployer{
49-
client: client,
50-
db: &b.StateDB,
51-
group: node.Group,
52-
resourceName: node.Key,
53-
settings: settings,
54-
}
55-
56-
if actionType == deployplan.ActionTypeDelete {
57-
err := d.Destroy(ctx)
46+
if d.ActionType == deployplan.ActionTypeDelete {
47+
err := d.Destroy(ctx, &b.StateDB)
5848
if err != nil {
5949
logdiag.LogError(ctx, fmt.Errorf("%s: %w", errorPrefix, err))
6050
return false
@@ -84,15 +74,15 @@ func (b *BundleDeployer) Apply(ctx context.Context, client *databricks.Workspace
8474

8575
// TODO: redo calcDiff to downgrade planned action if possible (?)
8676

87-
err = d.Deploy(ctx, config, actionType)
77+
err = d.Deploy(ctx, &b.StateDB, config, d.ActionType)
8878
if err != nil {
8979
logdiag.LogError(ctx, fmt.Errorf("%s: %w", errorPrefix, err))
9080
return false
9181
}
9282

9383
// Update resources.id after successful deploy so that future ${resources...id} refs are replaced
9484
if b.Graph.HasOutgoingEdges(node) {
95-
err = resolveIDReference(ctx, d.db, configRoot, node.Group, node.Key)
85+
err = resolveIDReference(ctx, &b.StateDB, configRoot, node.Group, node.Key)
9686
if err != nil {
9787
// not using errorPrefix because resource was deployed
9888
logdiag.LogError(ctx, fmt.Errorf("failed to replace ref to resources.%s.%s.id: %w", node.Group, node.Key, err))

0 commit comments

Comments
 (0)