@@ -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.
2922const 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.
3527const 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.
3931const 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.
4435const 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.
5042func 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.
10096type 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
105102type 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+
109114type 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
123128type 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.
139142type 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.
180182const 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.
184186const 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.
191194func 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.
255254func 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.
289276func exportBundleEnvSpec (cfg * runConfig ) exportedEnvSpec {
290277 channel := strings .TrimPrefix (defaultDlRuntimeImage , "CLIENT-GPU-" )
291278 if v , ok := cfg .runtimeVersion (); ok {
0 commit comments