Skip to content

Commit f31a1c2

Browse files
authored
feat: use plugin manifest (#4469)
## Changes <!-- Brief summary of your changes that is easy to understand --> - Replace the hardcoded features package with a manifest-driven system that reads appkit.plugins.json from templates, enabling templates to declaratively define their plugins, resources, and dependencies without CLI code changes - Add Helm-style --set flag (--set plugin.resourceKey.field=value) for non-interactive resource configuration, replacing the static --warehouse-id flag - Introduce interactive resource pickers for all 11 resource types (SQL warehouses, jobs, serving endpoints, secrets, databases, genie spaces, volumes, UC functions, UC connections, vector search indexes, experiments) with multi-step pickers for hierarchical resources (catalog → schema → volume/function, instance → database, scope → key) and back-navigation support ## Key design decisions - **resourceKey** is the unique identifier — not type. This allows multiple resources of the same type per plugin (e.g., two genie spaces with different keys) - **appResourceSpecs** data-driven map — Centralizes the mapping from manifest resource types to DABs YAML structure, eliminating per-type switch statements - Templates without appkit.plugins.json work gracefully — The app is created normally, just without resource configuration - **app** resource type is filtered out at runtime (with TODOs) until DABs supports it as an app resource --------- Co-authored-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
1 parent be5dada commit f31a1c2

File tree

11 files changed

+3288
-1420
lines changed

11 files changed

+3288
-1420
lines changed

cmd/apps/init.go

Lines changed: 469 additions & 400 deletions
Large diffs are not rendered by default.

cmd/apps/init_test.go

Lines changed: 198 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package apps
22

33
import (
4+
"context"
45
"errors"
56
"testing"
67

8+
"github.com/databricks/cli/libs/apps/manifest"
79
"github.com/databricks/cli/libs/apps/prompt"
810
"github.com/spf13/cobra"
911
"github.com/stretchr/testify/assert"
@@ -64,15 +66,15 @@ func TestIsTextFile(t *testing.T) {
6466
}
6567
}
6668

67-
func TestSubstituteVars(t *testing.T) {
69+
func TestExecuteTemplate(t *testing.T) {
70+
ctx := context.Background()
6871
vars := templateVars{
6972
ProjectName: "my-app",
70-
SQLWarehouseID: "warehouse123",
7173
AppDescription: "My awesome app",
7274
Profile: "default",
7375
WorkspaceHost: "https://dbc-123.cloud.databricks.com",
74-
PluginImport: "analytics",
75-
PluginUsage: "analytics()",
76+
PluginImports: "analytics",
77+
PluginUsages: "analytics()",
7678
}
7779

7880
tests := []struct {
@@ -85,11 +87,6 @@ func TestSubstituteVars(t *testing.T) {
8587
input: "name: {{.project_name}}",
8688
expected: "name: my-app",
8789
},
88-
{
89-
name: "warehouse id substitution",
90-
input: "warehouse: {{.sql_warehouse_id}}",
91-
expected: "warehouse: warehouse123",
92-
},
9390
{
9491
name: "description substitution",
9592
input: "description: {{.app_description}}",
@@ -102,17 +99,17 @@ func TestSubstituteVars(t *testing.T) {
10299
},
103100
{
104101
name: "workspace host substitution",
105-
input: "host: {{workspace_host}}",
102+
input: "host: {{.workspace_host}}",
106103
expected: "host: https://dbc-123.cloud.databricks.com",
107104
},
108105
{
109106
name: "plugin import substitution",
110-
input: "import { {{.plugin_import}} } from 'appkit'",
107+
input: "import { {{.plugin_imports}} } from 'appkit'",
111108
expected: "import { analytics } from 'appkit'",
112109
},
113110
{
114111
name: "plugin usage substitution",
115-
input: "plugins: [{{.plugin_usage}}]",
112+
input: "plugins: [{{.plugin_usages}}]",
116113
expected: "plugins: [analytics()]",
117114
},
118115
{
@@ -129,22 +126,20 @@ func TestSubstituteVars(t *testing.T) {
129126

130127
for _, tt := range tests {
131128
t.Run(tt.name, func(t *testing.T) {
132-
result := substituteVars(tt.input, vars)
133-
assert.Equal(t, tt.expected, result)
129+
result, err := executeTemplate(ctx, "test.txt", []byte(tt.input), vars)
130+
require.NoError(t, err)
131+
assert.Equal(t, tt.expected, string(result))
134132
})
135133
}
136134
}
137135

138-
func TestSubstituteVarsNoPlugins(t *testing.T) {
139-
// Test plugin cleanup when no plugins are selected
136+
func TestExecuteTemplateEmptyPlugins(t *testing.T) {
137+
ctx := context.Background()
140138
vars := templateVars{
141139
ProjectName: "my-app",
142-
SQLWarehouseID: "",
143140
AppDescription: "My app",
144-
Profile: "",
145-
WorkspaceHost: "",
146-
PluginImport: "", // No plugins
147-
PluginUsage: "",
141+
PluginImports: "",
142+
PluginUsages: "",
148143
}
149144

150145
tests := []struct {
@@ -153,25 +148,35 @@ func TestSubstituteVarsNoPlugins(t *testing.T) {
153148
expected string
154149
}{
155150
{
156-
name: "removes plugin import with comma",
157-
input: "import { core, {{.plugin_import}} } from 'appkit'",
151+
name: "empty plugin imports render as empty",
152+
input: "import { core{{if .plugin_imports}}, {{.plugin_imports}}{{end}} } from 'appkit'",
158153
expected: "import { core } from 'appkit'",
159154
},
160155
{
161-
name: "removes plugin usage line",
162-
input: "plugins: [\n {{.plugin_usage}},\n]",
163-
expected: "plugins: [\n]",
156+
name: "empty plugin usages render as empty",
157+
input: "plugins: [{{if .plugin_usages}}\n {{.plugin_usages}},\n{{end}}]",
158+
expected: "plugins: []",
164159
},
165160
}
166161

167162
for _, tt := range tests {
168163
t.Run(tt.name, func(t *testing.T) {
169-
result := substituteVars(tt.input, vars)
170-
assert.Equal(t, tt.expected, result)
164+
result, err := executeTemplate(ctx, "test.txt", []byte(tt.input), vars)
165+
require.NoError(t, err)
166+
assert.Equal(t, tt.expected, string(result))
171167
})
172168
}
173169
}
174170

171+
func TestExecuteTemplateInvalidSyntaxReturnsOriginal(t *testing.T) {
172+
ctx := context.Background()
173+
vars := templateVars{ProjectName: "my-app"}
174+
input := "some content with bad {{ syntax"
175+
result, err := executeTemplate(ctx, "test.js", []byte(input), vars)
176+
require.NoError(t, err)
177+
assert.Equal(t, input, string(result))
178+
}
179+
175180
func TestInitCmdBranchAndVersionMutuallyExclusive(t *testing.T) {
176181
cmd := newInitCmd()
177182
cmd.PreRunE = nil // skip workspace client setup for flag validation test
@@ -283,3 +288,168 @@ func TestParseDeployAndRunFlags(t *testing.T) {
283288
})
284289
}
285290
}
291+
292+
// testManifest returns a manifest with an "analytics" plugin for testing parseSetValues.
293+
func testManifest() *manifest.Manifest {
294+
return &manifest.Manifest{
295+
Plugins: map[string]manifest.Plugin{
296+
"analytics": {
297+
Name: "analytics",
298+
Resources: manifest.Resources{
299+
Required: []manifest.Resource{
300+
{
301+
Type: "sql_warehouse",
302+
Alias: "SQL Warehouse",
303+
ResourceKey: "sql-warehouse",
304+
Fields: map[string]manifest.ResourceField{"id": {Env: "WH_ID"}},
305+
},
306+
},
307+
Optional: []manifest.Resource{
308+
{
309+
Type: "database",
310+
Alias: "Database",
311+
ResourceKey: "database",
312+
Fields: map[string]manifest.ResourceField{
313+
"instance_name": {Env: "DB_INST"},
314+
"database_name": {Env: "DB_NAME"},
315+
},
316+
},
317+
{
318+
Type: "secret",
319+
Alias: "Secret",
320+
ResourceKey: "secret",
321+
Fields: map[string]manifest.ResourceField{
322+
"scope": {Env: "SECRET_SCOPE"},
323+
"key": {Env: "SECRET_KEY"},
324+
},
325+
},
326+
},
327+
},
328+
},
329+
},
330+
}
331+
}
332+
333+
func TestParseSetValues(t *testing.T) {
334+
m := testManifest()
335+
336+
tests := []struct {
337+
name string
338+
setValues []string
339+
wantRV map[string]string
340+
wantErr string
341+
}{
342+
{
343+
name: "single field",
344+
setValues: []string{"analytics.sql-warehouse.id=abc123"},
345+
wantRV: map[string]string{"sql-warehouse.id": "abc123"},
346+
},
347+
{
348+
name: "multi-field complete",
349+
setValues: []string{"analytics.database.instance_name=inst", "analytics.database.database_name=mydb"},
350+
wantRV: map[string]string{"database.instance_name": "inst", "database.database_name": "mydb"},
351+
},
352+
{
353+
name: "later set overrides earlier",
354+
setValues: []string{"analytics.sql-warehouse.id=first", "analytics.sql-warehouse.id=second"},
355+
wantRV: map[string]string{"sql-warehouse.id": "second"},
356+
},
357+
{
358+
name: "empty set values",
359+
setValues: nil,
360+
wantRV: map[string]string{},
361+
},
362+
{
363+
name: "missing equals sign",
364+
setValues: []string{"analytics.sql-warehouse.id"},
365+
wantErr: "invalid --set format",
366+
},
367+
{
368+
name: "too few key parts",
369+
setValues: []string{"sql-warehouse.id=abc"},
370+
wantErr: "invalid --set key",
371+
},
372+
{
373+
name: "unknown plugin",
374+
setValues: []string{"nosuch.sql-warehouse.id=abc"},
375+
wantErr: `unknown plugin "nosuch"`,
376+
},
377+
{
378+
name: "unknown resource key",
379+
setValues: []string{"analytics.nosuch.id=abc"},
380+
wantErr: `has no resource with key "nosuch"`,
381+
},
382+
{
383+
name: "unknown field",
384+
setValues: []string{"analytics.sql-warehouse.nosuch=abc"},
385+
wantErr: `field "nosuch"`,
386+
},
387+
{
388+
name: "multi-field incomplete database",
389+
setValues: []string{"analytics.database.instance_name=inst"},
390+
wantErr: `incomplete resource "database"`,
391+
},
392+
{
393+
name: "multi-field incomplete secret",
394+
setValues: []string{"analytics.secret.scope=myscope"},
395+
wantErr: `incomplete resource "secret"`,
396+
},
397+
{
398+
name: "all fields together",
399+
setValues: []string{
400+
"analytics.sql-warehouse.id=wh1",
401+
"analytics.database.instance_name=inst",
402+
"analytics.database.database_name=mydb",
403+
"analytics.secret.scope=s",
404+
"analytics.secret.key=k",
405+
},
406+
wantRV: map[string]string{
407+
"sql-warehouse.id": "wh1",
408+
"database.instance_name": "inst",
409+
"database.database_name": "mydb",
410+
"secret.scope": "s",
411+
"secret.key": "k",
412+
},
413+
},
414+
}
415+
416+
for _, tt := range tests {
417+
t.Run(tt.name, func(t *testing.T) {
418+
rv, err := parseSetValues(tt.setValues, m)
419+
if tt.wantErr != "" {
420+
require.Error(t, err)
421+
assert.Contains(t, err.Error(), tt.wantErr)
422+
} else {
423+
require.NoError(t, err)
424+
assert.Equal(t, tt.wantRV, rv)
425+
}
426+
})
427+
}
428+
}
429+
430+
func TestPluginHasResourceField(t *testing.T) {
431+
m := testManifest()
432+
p := m.GetPluginByName("analytics")
433+
require.NotNil(t, p)
434+
435+
assert.True(t, pluginHasResourceField(p, "sql-warehouse", "id"))
436+
assert.True(t, pluginHasResourceField(p, "database", "instance_name"))
437+
assert.True(t, pluginHasResourceField(p, "secret", "scope"))
438+
assert.False(t, pluginHasResourceField(p, "sql-warehouse", "nosuch"))
439+
assert.False(t, pluginHasResourceField(p, "nosuch", "id"))
440+
}
441+
442+
func TestAppendUnique(t *testing.T) {
443+
result := appendUnique([]string{"a", "b"}, "b", "c", "a", "d")
444+
assert.Equal(t, []string{"a", "b", "c", "d"}, result)
445+
}
446+
447+
func TestAppendUniqueEmptyBase(t *testing.T) {
448+
result := appendUnique(nil, "x", "y", "x")
449+
assert.Equal(t, []string{"x", "y"}, result)
450+
}
451+
452+
func TestAppendUniqueNoValues(t *testing.T) {
453+
result := appendUnique([]string{"a", "b"})
454+
assert.Equal(t, []string{"a", "b"}, result)
455+
}

0 commit comments

Comments
 (0)