Skip to content

Commit e622ab5

Browse files
authored
Added support for bundle generate and bind for Apps (#1946)
## Changes Added support for bundle generate and bind for Apps ## Tests - [ ] Add E2E test
1 parent e9e0566 commit e622ab5

File tree

5 files changed

+243
-0
lines changed

5 files changed

+243
-0
lines changed

bundle/config/generate/app.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package generate
2+
3+
import (
4+
"github.com/databricks/cli/libs/dyn"
5+
"github.com/databricks/cli/libs/dyn/convert"
6+
"github.com/databricks/databricks-sdk-go/service/apps"
7+
)
8+
9+
func ConvertAppToValue(app *apps.App, sourceCodePath string, appConfig map[string]any) (dyn.Value, error) {
10+
ac, err := convert.FromTyped(appConfig, dyn.NilValue)
11+
if err != nil {
12+
return dyn.NilValue, err
13+
}
14+
15+
ar, err := convert.FromTyped(app.Resources, dyn.NilValue)
16+
if err != nil {
17+
return dyn.NilValue, err
18+
}
19+
20+
// The majority of fields of the app struct are read-only.
21+
// We copy the relevant fields manually.
22+
dv := map[string]dyn.Value{
23+
"name": dyn.NewValue(app.Name, []dyn.Location{{Line: 1}}),
24+
"description": dyn.NewValue(app.Description, []dyn.Location{{Line: 2}}),
25+
"source_code_path": dyn.NewValue(sourceCodePath, []dyn.Location{{Line: 3}}),
26+
}
27+
28+
if ac.Kind() != dyn.KindNil {
29+
dv["config"] = ac.WithLocations([]dyn.Location{{Line: 4}})
30+
}
31+
32+
if ar.Kind() != dyn.KindNil {
33+
dv["resources"] = ar.WithLocations([]dyn.Location{{Line: 5}})
34+
}
35+
36+
return dyn.V(dv), nil
37+
}

bundle/config/resources.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,19 @@ func (r *Resources) FindResourceByConfigKey(key string) (ConfigResource, error)
9999
found = append(found, r.Jobs[k])
100100
}
101101
}
102+
102103
for k := range r.Pipelines {
103104
if k == key {
104105
found = append(found, r.Pipelines[k])
105106
}
106107
}
107108

109+
for k := range r.Apps {
110+
if k == key {
111+
found = append(found, r.Apps[k])
112+
}
113+
}
114+
108115
if len(found) == 0 {
109116
return nil, fmt.Errorf("no such resource: %s", key)
110117
}

cmd/bundle/generate.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ func newGenerateCommand() *cobra.Command {
1717
cmd.AddCommand(generate.NewGenerateJobCommand())
1818
cmd.AddCommand(generate.NewGeneratePipelineCommand())
1919
cmd.AddCommand(generate.NewGenerateDashboardCommand())
20+
cmd.AddCommand(generate.NewGenerateAppCommand())
2021
cmd.PersistentFlags().StringVar(&key, "key", "", `resource key to use for the generated configuration`)
2122
return cmd
2223
}

cmd/bundle/generate/app.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package generate
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"io/fs"
9+
"path/filepath"
10+
11+
"github.com/databricks/cli/bundle/config/generate"
12+
"github.com/databricks/cli/cmd/root"
13+
"github.com/databricks/cli/libs/cmdio"
14+
"github.com/databricks/cli/libs/dyn"
15+
"github.com/databricks/cli/libs/dyn/yamlsaver"
16+
"github.com/databricks/cli/libs/filer"
17+
"github.com/databricks/cli/libs/textutil"
18+
"github.com/databricks/databricks-sdk-go"
19+
"github.com/databricks/databricks-sdk-go/service/apps"
20+
"github.com/spf13/cobra"
21+
22+
"gopkg.in/yaml.v3"
23+
)
24+
25+
func NewGenerateAppCommand() *cobra.Command {
26+
var configDir string
27+
var sourceDir string
28+
var appName string
29+
var force bool
30+
31+
cmd := &cobra.Command{
32+
Use: "app",
33+
Short: "Generate bundle configuration for a Databricks app",
34+
}
35+
36+
cmd.Flags().StringVar(&appName, "existing-app-name", "", `App name to generate config for`)
37+
cmd.MarkFlagRequired("existing-app-name")
38+
39+
cmd.Flags().StringVarP(&configDir, "config-dir", "d", filepath.Join("resources"), `Directory path where the output bundle config will be stored`)
40+
cmd.Flags().StringVarP(&sourceDir, "source-dir", "s", filepath.Join("src", "app"), `Directory path where the app files will be stored`)
41+
cmd.Flags().BoolVarP(&force, "force", "f", false, `Force overwrite existing files in the output directory`)
42+
43+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
44+
ctx := cmd.Context()
45+
b, diags := root.MustConfigureBundle(cmd)
46+
if err := diags.Error(); err != nil {
47+
return diags.Error()
48+
}
49+
50+
w := b.WorkspaceClient()
51+
cmdio.LogString(ctx, fmt.Sprintf("Loading app '%s' configuration", appName))
52+
app, err := w.Apps.Get(ctx, apps.GetAppRequest{Name: appName})
53+
if err != nil {
54+
return err
55+
}
56+
57+
// Making sure the config directory and source directory are absolute paths.
58+
if !filepath.IsAbs(configDir) {
59+
configDir = filepath.Join(b.BundleRootPath, configDir)
60+
}
61+
62+
if !filepath.IsAbs(sourceDir) {
63+
sourceDir = filepath.Join(b.BundleRootPath, sourceDir)
64+
}
65+
66+
downloader := newDownloader(w, sourceDir, configDir)
67+
68+
sourceCodePath := app.DefaultSourceCodePath
69+
err = downloader.markDirectoryForDownload(ctx, &sourceCodePath)
70+
if err != nil {
71+
return err
72+
}
73+
74+
appConfig, err := getAppConfig(ctx, app, w)
75+
if err != nil {
76+
return fmt.Errorf("failed to get app config: %w", err)
77+
}
78+
79+
// Making sure the source code path is relative to the config directory.
80+
rel, err := filepath.Rel(configDir, sourceDir)
81+
if err != nil {
82+
return err
83+
}
84+
85+
v, err := generate.ConvertAppToValue(app, filepath.ToSlash(rel), appConfig)
86+
if err != nil {
87+
return err
88+
}
89+
90+
appKey := cmd.Flag("key").Value.String()
91+
if appKey == "" {
92+
appKey = textutil.NormalizeString(app.Name)
93+
}
94+
95+
result := map[string]dyn.Value{
96+
"resources": dyn.V(map[string]dyn.Value{
97+
"apps": dyn.V(map[string]dyn.Value{
98+
appKey: v,
99+
}),
100+
}),
101+
}
102+
103+
// If there are app.yaml or app.yml files in the source code path, they will be downloaded but we don't want to include them in the bundle.
104+
// We include this configuration inline, so we need to remove these files.
105+
for _, configFile := range []string{"app.yml", "app.yaml"} {
106+
delete(downloader.files, filepath.Join(sourceDir, configFile))
107+
}
108+
109+
err = downloader.FlushToDisk(ctx, force)
110+
if err != nil {
111+
return err
112+
}
113+
114+
filename := filepath.Join(configDir, fmt.Sprintf("%s.app.yml", appKey))
115+
116+
saver := yamlsaver.NewSaver()
117+
err = saver.SaveAsYAML(result, filename, force)
118+
if err != nil {
119+
return err
120+
}
121+
122+
cmdio.LogString(ctx, fmt.Sprintf("App configuration successfully saved to %s", filename))
123+
return nil
124+
}
125+
126+
return cmd
127+
}
128+
129+
func getAppConfig(ctx context.Context, app *apps.App, w *databricks.WorkspaceClient) (map[string]any, error) {
130+
sourceCodePath := app.DefaultSourceCodePath
131+
132+
f, err := filer.NewWorkspaceFilesClient(w, sourceCodePath)
133+
if err != nil {
134+
return nil, err
135+
}
136+
137+
// The app config is stored in app.yml or app.yaml file in the source code path.
138+
configFileNames := []string{"app.yml", "app.yaml"}
139+
for _, configFile := range configFileNames {
140+
r, err := f.Read(ctx, configFile)
141+
if err != nil {
142+
if errors.Is(err, fs.ErrNotExist) {
143+
continue
144+
}
145+
return nil, err
146+
}
147+
defer r.Close()
148+
149+
cmdio.LogString(ctx, fmt.Sprintf("Reading app configuration from %s", configFile))
150+
content, err := io.ReadAll(r)
151+
if err != nil {
152+
return nil, err
153+
}
154+
155+
var appConfig map[string]any
156+
err = yaml.Unmarshal(content, &appConfig)
157+
if err != nil {
158+
cmdio.LogString(ctx, fmt.Sprintf("Failed to parse app configuration:\n%s\nerr: %v", string(content), err))
159+
return nil, nil
160+
}
161+
162+
return appConfig, nil
163+
}
164+
165+
return nil, nil
166+
}

cmd/bundle/generate/utils.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/databricks/databricks-sdk-go"
1414
"github.com/databricks/databricks-sdk-go/service/jobs"
1515
"github.com/databricks/databricks-sdk-go/service/pipelines"
16+
"github.com/databricks/databricks-sdk-go/service/workspace"
1617
"golang.org/x/sync/errgroup"
1718
)
1819

@@ -63,6 +64,37 @@ func (n *downloader) markFileForDownload(ctx context.Context, filePath *string)
6364
return nil
6465
}
6566

67+
func (n *downloader) markDirectoryForDownload(ctx context.Context, dirPath *string) error {
68+
_, err := n.w.Workspace.GetStatusByPath(ctx, *dirPath)
69+
if err != nil {
70+
return err
71+
}
72+
73+
objects, err := n.w.Workspace.RecursiveList(ctx, *dirPath)
74+
if err != nil {
75+
return err
76+
}
77+
78+
for _, obj := range objects {
79+
if obj.ObjectType == workspace.ObjectTypeDirectory {
80+
continue
81+
}
82+
83+
err := n.markFileForDownload(ctx, &obj.Path)
84+
if err != nil {
85+
return err
86+
}
87+
}
88+
89+
rel, err := filepath.Rel(n.configDir, n.sourceDir)
90+
if err != nil {
91+
return err
92+
}
93+
94+
*dirPath = rel
95+
return nil
96+
}
97+
6698
func (n *downloader) markNotebookForDownload(ctx context.Context, notebookPath *string) error {
6799
info, err := n.w.Workspace.GetStatusByPath(ctx, *notebookPath)
68100
if err != nil {

0 commit comments

Comments
 (0)