Skip to content

Commit f01b540

Browse files
committed
[air] Deliver code_source via DABs immutable_folder; trim comments
Wires code_source snapshots through the DABs content-addressed snapshot path (experimental.immutable_folder) so `air run` uploads the user's code tree the same zip-and-fingerprint way AIR does today — giving an apples-to-apples benchmark against the mechanism the uploads-vs-DABs scoping doc compares (DECO-27087), not the per-file bundle sync. - exportbundle.go: emit experimental.immutable_folder=true; emit command_path as a bundle-root-relative path so translate_paths rewrites it to the deployed snapshot location (emitting ${workspace.file_path} directly is validated as a local file and fails). The convertibility gate now allows working-tree code_source snapshots but still rejects git-pinned (working-tree upload can't pin a commit) and remote_volume (immutable_folder is Workspace-Files-only) snapshots. - rundabs.go: writeBundleProject copies the code_source working tree into the bundle root (honoring include_paths) so deploy uploads it in the immutable snapshot. - Trim AI-narration comments across the air files (provenance dumps, verification history, migration storytelling) down to what/why. Verified on staging: `air run` with a code_source snapshot deploys via "Uploading immutable bundle snapshot..." and submits a run. (The AI Runtime execution then fails on a backend PrincipalContext/entity-owner error, independent of code delivery and of the CLI path.) Co-authored-by: Isaac
1 parent a12413e commit f01b540

7 files changed

Lines changed: 261 additions & 182 deletions

File tree

experimental/air/cmd/cancel.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,6 @@ func runNotFound(err error) bool {
191191

192192
// cancelRun requests cancellation of a single job run. The cancel is async, so
193193
// the returned waiter is ignored.
194-
//
195-
// DABs note: this works unchanged for DABs-submitted runs. A `bundle run` produces
196-
// a normal Jobs run with a run_id, and cancelling that run terminates the AIR
197-
// workload the same way — BYOT/AICM dispatch is keyed on task type, not on how the
198-
// run was submitted. (`cancel --all` discovers runs via `air list`, which the
199-
// --via-dabs list mode widens to include DABs JOB_RUNs.)
200194
func cancelRun(ctx context.Context, w *databricks.WorkspaceClient, rid string) error {
201195
runID, err := strconv.ParseInt(rid, 10, 64)
202196
if err != nil || runID <= 0 {

experimental/air/cmd/exportbundle.go

Lines changed: 69 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -8,54 +8,40 @@ import (
88
"go.yaml.in/yaml/v3"
99
)
1010

11-
// This file implements the train.yaml -> databricks.yml conversion behind
12-
// `air export-bundle`: a one-time converter that turns an AIR run YAML into a
13-
// Databricks Asset Bundle deploying the same workload as a native ai_runtime_task.
14-
// It is the durable counterpart to `air run` (which submits an ephemeral one-time
15-
// run): the emitted bundle is a persistent Jobs resource the user owns and deploys
16-
// with `databricks bundle deploy`.
11+
// This file converts a train.yaml runConfig to a databricks.yml Asset Bundle
12+
// deploying the same workload as a native ai_runtime_task. It backs both
13+
// `air export-bundle` and the `air run` submit path (rundabs.go).
1714
//
18-
// The field mapping mirrors buildSubmitPayload (runsubmit.go) so the generated
19-
// task is the shape `air run` would submit. Structural validity is already
20-
// guaranteed upstream by runConfig.validate(); this file adds a second,
21-
// converter-specific gate — checkBundleConvertible — that rejects the configs a
22-
// bundle cannot represent faithfully, rather than emitting a databricks.yml that
23-
// deploys but misbehaves. The gate exists because train.yaml and a bundle are not
24-
// 1:1: some run fields have no bundle equivalent, and some assume the AIR run
25-
// harness (e.g. $CODE_SOURCE_PATH) that a bundle does not provide.
15+
// checkBundleConvertible rejects configs a bundle cannot represent faithfully:
16+
// train.yaml and a bundle are not 1:1, and some run fields have no bundle
17+
// equivalent or assume the AIR run harness (e.g. $CODE_SOURCE_PATH) a bundle does
18+
// not provide.
2619

2720
// bundleCommandScript is the entrypoint filename the emitted bundle references and
2821
// that `bundle sync` uploads alongside the user's code.
2922
const bundleCommandScript = "command.sh"
3023

3124
// codeSourcePathVar is the environment variable the AIR run harness sets to the
3225
// extracted snapshot directory. A bundle delivers code via `bundle sync` and never
33-
// sets it, so a command relying on it would break at runtime — the convertibility
34-
// gate rejects such commands.
26+
// sets it, so the gate rejects commands relying on it.
3527
const codeSourcePathVar = "$CODE_SOURCE_PATH"
3628

3729
// workspaceFilePathRef is the bundle variable that resolves to where `bundle
3830
// deploy` syncs this folder; the emitted command_path points under it.
3931
const workspaceFilePathRef = "${workspace.file_path}"
4032

41-
// aiRuntimeEnvVarsKey is the key linking the task's environment_variables_key to
42-
// the single job-level env-var profile the converter emits. Mirrors
43-
// AI_RUNTIME_ENV_VARS_KEY in the Python CLI (common Jobs env-var API, API-293).
33+
// aiRuntimeEnvVarsKey links the task's environment_variables_key to the single
34+
// job-level env-var profile the converter emits.
4435
const aiRuntimeEnvVarsKey = "default"
4536

4637
// checkBundleConvertible reports why a structurally-valid runConfig cannot be
47-
// converted to a faithful bundle, or nil if it can. Every reason names the source
48-
// field and why it can't be represented, so the CLI can reject with an actionable
49-
// message instead of emitting a lossy databricks.yml.
38+
// converted to a faithful bundle, or nil if it can. Each reason names the source
39+
// field so the CLI can reject with an actionable message rather than emit a lossy
40+
// databricks.yml. env_variables/secrets are representable (see envVarProfiles) and
41+
// are not rejected here.
5042
func checkBundleConvertible(cfg *runConfig) error {
5143
var reasons []string
5244

53-
// env_variables / secrets ARE now representable: they ride the common Jobs
54-
// env-var API (API-293) as a job-level environment_variables profile the task
55-
// references by key. convertToBundle emits that profile, so no rejection here.
56-
// (Verified on staging: the profile persists on runs/get; secrets are emitted
57-
// as {{secrets/scope/key}} refs resolved by Jobs at run time.)
58-
5945
// docker_image: a custom image must be registered before it can be referenced;
6046
// that registration is not part of a bundle deploy.
6147
if cfg.dockerImageURL() != "" {
@@ -71,11 +57,21 @@ func checkBundleConvertible(cfg *runConfig) error {
7157
reasons = append(reasons, "usage_policy_id: budget policy binding is not represented on the ai_runtime_task bundle path yet")
7258
}
7359

74-
// code_source with a git ref: bundle sync uploads the working tree; it cannot
75-
// pin to a specific commit or fetch a remote branch (the R1/R3 gap in the
76-
// uploads-vs-DABs design doc). Dropping the pin would silently change what runs.
77-
if cfg.CodeSource != nil && cfg.CodeSource.Snapshot != nil && cfg.CodeSource.Snapshot.Git != nil {
78-
reasons = append(reasons, "code_source.snapshot.git: bundle sync cannot pin to a git commit or fetch a remote branch")
60+
// code_source snapshots are delivered by uploading the working tree as an
61+
// immutable-folder snapshot (see writeBundleProject). Two sub-cases can't be
62+
// represented that way:
63+
if cfg.CodeSource != nil && cfg.CodeSource.Snapshot != nil {
64+
snap := cfg.CodeSource.Snapshot
65+
// git ref: the snapshot uploads the live working tree; it cannot pin to a
66+
// commit or fetch a remote branch. Dropping the pin would change what runs.
67+
if snap.Git != nil {
68+
reasons = append(reasons, "code_source.snapshot.git: the immutable-folder snapshot uploads the working tree and cannot pin a git commit or fetch a remote branch")
69+
}
70+
// remote_volume: the immutable-folder snapshot uploads to Workspace Files
71+
// only, not a UC Volume.
72+
if snap.RemoteVolume != nil {
73+
reasons = append(reasons, "code_source.snapshot.remote_volume: the immutable-folder snapshot uploads to Workspace Files, not a UC Volume")
74+
}
7975
}
8076

8177
// A command that reads $CODE_SOURCE_PATH assumes the AIR run harness, which a
@@ -98,14 +94,23 @@ func checkBundleConvertible(cfg *runConfig) error {
9894
// name plus one job with a single ai_runtime_task. It marshals to YAML, so field
9995
// order here is the emitted key order.
10096
type exportedBundle struct {
101-
Bundle bundleBlock `yaml:"bundle"`
102-
Resources exportedResourcesBlock `yaml:"resources"`
97+
Bundle bundleBlock `yaml:"bundle"`
98+
Resources exportedResourcesBlock `yaml:"resources"`
99+
Experimental *exportedExperimental `yaml:"experimental,omitempty"`
103100
}
104101

105102
type bundleBlock struct {
106103
Name string `yaml:"name"`
107104
}
108105

106+
// exportedExperimental carries experimental.immutable_folder. air run uploads the
107+
// synced code as a single content-addressed snapshot (/api/2.0/repos/snapshots)
108+
// rather than per-file — the mechanism that mirrors AIR's own zip-and-fingerprint
109+
// model. Requires the direct deployment engine, which the run path uses.
110+
type exportedExperimental struct {
111+
ImmutableFolder bool `yaml:"immutable_folder"`
112+
}
113+
109114
type exportedResourcesBlock struct {
110115
Jobs map[string]exportedJob `yaml:"jobs"`
111116
}
@@ -114,28 +119,26 @@ type exportedJob struct {
114119
Name string `yaml:"name"`
115120
Tasks []exportedTask `yaml:"tasks"`
116121
Environments []exportedEnvironment `yaml:"environments"`
117-
// EnvironmentVariables carries env-var profiles via the common Jobs env-var API
118-
// (API-293). Emitted only when the run declares env_variables/secrets; the task
119-
// references a profile by key (see exportedTask.EnvironmentVariablesKey).
122+
// EnvironmentVariables holds env-var profiles, referenced by a task's
123+
// EnvironmentVariablesKey. Emitted only when the run declares env_variables or
124+
// secrets.
120125
EnvironmentVariables []exportedEnvVarProfile `yaml:"environment_variables,omitempty"`
121126
}
122127

123128
type exportedTask struct {
124129
TaskKey string `yaml:"task_key"`
125130
EnvironmentKey string `yaml:"environment_key"`
126-
// EnvironmentVariablesKey references a job-level environment_variables profile
127-
// (common Jobs env-var API). Omitted when the run has no env vars/secrets so the
128-
// wire form matches the submit path, which sets it only under the same gate.
131+
// EnvironmentVariablesKey references a job-level environment_variables profile.
132+
// Omitted when the run has no env vars or secrets.
129133
EnvironmentVariablesKey string `yaml:"environment_variables_key,omitempty"`
130134
MaxRetries int `yaml:"max_retries"`
131135
TimeoutSeconds int `yaml:"timeout_seconds,omitempty"`
132136
AiRuntimeTask exportedAiRuntimeTask `yaml:"ai_runtime_task"`
133137
}
134138

135-
// exportedEnvVarProfile is one entry in the job-level environment_variables list
136-
// (common Jobs env-var API). variables holds plain values inline and secrets as
137-
// {{secrets/scope/key}} references, resolved by Jobs at run time — the exact shape
138-
// the ai_runtime_task submit path emits (jobs_api_client.py, API-293).
139+
// exportedEnvVarProfile is one entry in the job-level environment_variables list.
140+
// Variables holds plain values inline and secrets as {{secrets/scope/key}}
141+
// references, resolved by Jobs at run time.
139142
type exportedEnvVarProfile struct {
140143
EnvironmentVariablesKey string `yaml:"environment_variables_key"`
141144
Variables map[string]string `yaml:"variables"`
@@ -175,28 +178,26 @@ type exportedEnvSpec struct {
175178
}
176179

177180
// databricksAITokenPrefix marks an environment.version that selects the
178-
// databricks-ai managed base environment rather than a bare channel. Mirrors
179-
// _DATABRICKS_AI_TOKEN_PREFIX in the Python CLI's jobs_api_client.py.
181+
// databricks-ai managed base environment rather than a bare channel.
180182
const databricksAITokenPrefix = "databricks_ai_v"
181183

182184
// databricksAIBaseEnvironment is the system base_environment id the databricks-ai
183-
// token resolves to. Mirrors _DATABRICKS_AI_BASE_ENVIRONMENT in the Python CLI.
185+
// token resolves to.
184186
const databricksAIBaseEnvironment = "workspace-base-environments/"
185187

186-
// convertToBundle maps a convertible runConfig to the emitted bundle. It assumes
187-
// checkBundleConvertible has already passed. The command_path points at the synced
188-
// command.sh under ${workspace.file_path}; the runtime channel is resolved the same
189-
// way `air run` resolves it (config version, else the default), minus the process
190-
// env lookup so a generated artifact is reproducible rather than host-dependent.
188+
// convertToBundle maps a convertible runConfig to the emitted bundle, assuming
189+
// checkBundleConvertible has already passed. command_path is emitted as a path
190+
// relative to the bundle root; the bundle's translate_paths mutator rewrites it to
191+
// the deployed location (for immutable_folder, under the content-addressed
192+
// snapshot). Emitting ${workspace.file_path} directly would instead be validated as
193+
// a local file and fail.
191194
func convertToBundle(cfg *runConfig) *exportedBundle {
192195
task := exportedAiRuntimeTask{
193196
Experiment: cfg.ExperimentName,
194197
Deployments: []exportedDeployment{{
195-
// `air run` submits a single unnamed deployment; the bundle names it
196-
// "worker" so the authored YAML reads clearly. The name is cosmetic (the
197-
// submit payload carries none), so this does not change behavior.
198+
// The deployment name is cosmetic (the wire payload carries none).
198199
Name: "worker",
199-
CommandPath: workspaceFilePathRef + "/" + bundleCommandScript,
200+
CommandPath: bundleCommandScript,
200201
Compute: exportedCompute{
201202
AcceleratorType: cfg.Compute.AcceleratorType,
202203
AcceleratorCount: cfg.Compute.NumAccelerators,
@@ -218,16 +219,15 @@ func convertToBundle(cfg *runConfig) *exportedBundle {
218219
AiRuntimeTask: task,
219220
}
220221

221-
// Env vars ride the common Jobs env-var API: one job-level profile, referenced
222-
// by the task's environment_variables_key. Emitted only when there are vars or
223-
// secrets, so a var-free run's wire form matches the submit path exactly.
222+
// One job-level env-var profile, referenced by the task's
223+
// environment_variables_key. Emitted only when there are vars or secrets.
224224
profiles := envVarProfiles(cfg)
225225
if len(profiles) > 0 {
226226
tsk.EnvironmentVariablesKey = aiRuntimeEnvVarsKey
227227
}
228228

229-
// Resource key shares the task_key charset, which validateExperimentName
230-
// already guarantees, so the experiment name is safe to use verbatim.
229+
// The experiment name is safe as a resource key: validateExperimentName already
230+
// guarantees the task_key charset.
231231
return &exportedBundle{
232232
Bundle: bundleBlock{Name: cfg.ExperimentName},
233233
Resources: exportedResourcesBlock{
@@ -243,15 +243,14 @@ func convertToBundle(cfg *runConfig) *exportedBundle {
243243
},
244244
},
245245
},
246+
Experimental: &exportedExperimental{ImmutableFolder: true},
246247
}
247248
}
248249

249250
// envVarProfiles builds the job-level env-var profile list from the run's
250251
// env_variables and secrets, or nil when there are none. Plain values are inline;
251252
// each secret (ENV_VAR -> "scope/key") becomes a {{secrets/scope/key}} reference
252-
// Jobs resolves at run time. This mirrors the Python CLI's ai_runtime_task path
253-
// (jobs_api_client.py, API-293) and was verified against staging: the profile
254-
// persists on runs/get and secret refs resolve at run time.
253+
// Jobs resolves at run time.
255254
func envVarProfiles(cfg *runConfig) []exportedEnvVarProfile {
256255
if len(cfg.EnvVariables) == 0 && len(cfg.Secrets) == 0 {
257256
return nil
@@ -269,23 +268,11 @@ func envVarProfiles(cfg *runConfig) []exportedEnvVarProfile {
269268
}}
270269
}
271270

272-
// exportBundleEnvSpec resolves the serverless runtime selection for the bundle,
273-
// branching on environment.version the same way the Python CLI's jobs_api_client
274-
// does (jobs_api_client.py:1562-1565): a "databricks_ai_v<N>" token selects the
275-
// managed databricks-ai base_environment (torch + ML venv), while a bare numeric
276-
// channel ("4", "5", ...) uses environment_version. Unlike dlRuntimeImage it does
277-
// not read process env, so the generated bundle is reproducible. A requirements-file
278-
// dependency set carries its version in the file, so this falls back to the default
279-
// channel there (same as the submit path).
280-
//
281-
// TODO(air): the Go `air run` submit path (runsubmit.go) only ever emits
282-
// environment_version and cannot select base_environment, so a `version:
283-
// databricks_ai_v<N>` run lands on a bare GPU channel without torch/mlflow and
284-
// fails at import. This converter hardcodes the correct base_environment branch so
285-
// exported bundles run today; once `air run` forwards env vars/dependencies through
286-
// the new BYOT common Jobs API (env_file/env_var work, ETA EOQ2), the submit path
287-
// and this converter should share one runtime-selection helper instead of
288-
// duplicating the branch.
271+
// exportBundleEnvSpec resolves the serverless runtime selection: a
272+
// "databricks_ai_v<N>" token selects the managed databricks-ai base_environment
273+
// (torch + ML venv), while a bare numeric channel ("4", "5", ...) uses
274+
// environment_version. It does not read process env, so the generated bundle is
275+
// reproducible.
289276
func exportBundleEnvSpec(cfg *runConfig) exportedEnvSpec {
290277
channel := strings.TrimPrefix(defaultDlRuntimeImage, "CLIENT-GPU-")
291278
if v, ok := cfg.runtimeVersion(); ok {

experimental/air/cmd/exportbundle_test.go

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package aircmd
22

33
import (
4+
"os"
5+
"path/filepath"
46
"strings"
57
"testing"
68

@@ -31,7 +33,9 @@ func TestConvertToBundleBasic(t *testing.T) {
3133

3234
require.Len(t, task.AiRuntimeTask.Deployments, 1)
3335
dep := task.AiRuntimeTask.Deployments[0]
34-
assert.Equal(t, workspaceFilePathRef+"/"+bundleCommandScript, dep.CommandPath)
36+
// command_path is relative to the bundle root; translate_paths rewrites it to
37+
// the deployed location.
38+
assert.Equal(t, bundleCommandScript, dep.CommandPath)
3539
assert.Equal(t, "GPU_8xH100", dep.Compute.AcceleratorType)
3640
assert.Equal(t, 16, dep.Compute.AcceleratorCount)
3741

@@ -93,9 +97,67 @@ func TestCheckBundleConvertibleRejectsCodeSourcePathCommand(t *testing.T) {
9397
assert.Contains(t, err.Error(), codeSourcePathVar)
9498
}
9599

100+
func TestCheckBundleConvertibleCodeSource(t *testing.T) {
101+
base := func() *runConfig {
102+
return &runConfig{
103+
ExperimentName: "exp",
104+
Command: new("python train.py"),
105+
Compute: &computeConfig{AcceleratorType: "GPU_1xA10", NumAccelerators: 1},
106+
}
107+
}
108+
109+
// A plain working-tree snapshot is convertible: it uploads via immutable folder.
110+
ok := base()
111+
ok.CodeSource = &codeSourceConfig{Type: "snapshot", Snapshot: &snapshotSourceConfig{RootPath: "."}}
112+
assert.NoError(t, checkBundleConvertible(ok))
113+
114+
// A git-pinned snapshot can't be represented (working-tree upload only).
115+
git := base()
116+
git.CodeSource = &codeSourceConfig{Type: "snapshot", Snapshot: &snapshotSourceConfig{RootPath: ".", Git: &gitRef{Commit: new("abc123")}}}
117+
err := checkBundleConvertible(git)
118+
require.Error(t, err)
119+
assert.Contains(t, err.Error(), "git")
120+
121+
// A UC Volume destination isn't supported (Workspace Files only).
122+
vol := base()
123+
vol.CodeSource = &codeSourceConfig{Type: "snapshot", Snapshot: &snapshotSourceConfig{RootPath: ".", RemoteVolume: new("/Volumes/c/s/v")}}
124+
err = checkBundleConvertible(vol)
125+
require.Error(t, err)
126+
assert.Contains(t, err.Error(), "remote_volume")
127+
}
128+
129+
func TestStageCodeSource(t *testing.T) {
130+
// A working-tree snapshot copies the tree into the bundle root; include_paths
131+
// restricts to the named subpaths.
132+
src := t.TempDir()
133+
require.NoError(t, os.WriteFile(filepath.Join(src, "train.py"), []byte("print()"), 0o644))
134+
require.NoError(t, os.MkdirAll(filepath.Join(src, "pkg"), 0o755))
135+
require.NoError(t, os.WriteFile(filepath.Join(src, "pkg", "mod.py"), []byte("x=1"), 0o644))
136+
require.NoError(t, os.WriteFile(filepath.Join(src, "ignore.txt"), []byte("no"), 0o644))
137+
138+
t.Run("whole tree", func(t *testing.T) {
139+
dst := t.TempDir()
140+
snap := &snapshotSourceConfig{RootPath: src}
141+
require.NoError(t, stageCodeSource(t.Context(), snap, "train.yaml", dst))
142+
assert.FileExists(t, filepath.Join(dst, "train.py"))
143+
assert.FileExists(t, filepath.Join(dst, "pkg", "mod.py"))
144+
assert.FileExists(t, filepath.Join(dst, "ignore.txt"))
145+
})
146+
147+
t.Run("include_paths only", func(t *testing.T) {
148+
dst := t.TempDir()
149+
snap := &snapshotSourceConfig{RootPath: src, IncludePaths: []string{"train.py", "pkg"}}
150+
require.NoError(t, stageCodeSource(t.Context(), snap, "train.yaml", dst))
151+
assert.FileExists(t, filepath.Join(dst, "train.py"))
152+
assert.FileExists(t, filepath.Join(dst, "pkg", "mod.py"))
153+
assert.NoFileExists(t, filepath.Join(dst, "ignore.txt"))
154+
})
155+
}
156+
96157
func TestRenderBundleIncludesTargetsAndConvertGate(t *testing.T) {
97-
// renderBundle (what --dry-run --via-dabs shows and what the run path deploys)
98-
// must include the converted job AND the appended dev targets block.
158+
// renderBundle (what --dry-run shows and what the run path deploys) must include
159+
// the converted job, the appended dev targets block, and the immutable_folder
160+
// flag that routes deploy through the content-addressed snapshot path.
99161
cfg := &runConfig{
100162
ExperimentName: "exp",
101163
Command: new("python train.py"),
@@ -108,6 +170,7 @@ func TestRenderBundleIncludesTargetsAndConvertGate(t *testing.T) {
108170
assert.Contains(t, out, "FOO: bar")
109171
assert.Contains(t, out, "targets:")
110172
assert.Contains(t, out, "mode: development")
173+
assert.Contains(t, out, "immutable_folder: true")
111174

112175
// The convertibility gate still applies: an unconvertible config errors instead
113176
// of rendering a lossy bundle.

experimental/air/cmd/get.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,6 @@ func authError(ctx context.Context, cmd *cobra.Command, err error) error {
9191

9292
// newGetCommand returns the `air get JOB_RUN_ID` command, which shows status,
9393
// configuration, and timing details for a specific run.
94-
// get works unchanged for DABs-submitted runs: it resolves by run_id via
95-
// Jobs.GetRun, and a `bundle run` produces a normal Jobs run with a run_id. No
96-
// submit-verb-specific handling is needed.
9794
func newGetCommand() *cobra.Command {
9895
cmd := &cobra.Command{
9996
Use: "get JOB_RUN_ID",

0 commit comments

Comments
 (0)