Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion acceptance/bundle/validate/strict/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Warning: invalid value "INVALID_TYPE" for enum field. Valid values are [complex]
at variables.my_variable.type
in databricks.yml:6:11

Warning: invalid value "INVALID_TYPE" for enum field. Valid values are [whl jar]
Warning: invalid value "INVALID_TYPE" for enum field. Valid values are [whl jar tgz]
at artifacts.my_artifact.type
in databricks.yml:16:15

Expand Down
16 changes: 16 additions & 0 deletions bundle/artifacts/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ func (m *build) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {

}

// A `tgz` artifact with `include`/`git` (and no user `build` command) is built
// by DABs itself. Produce the tarball, then expand globs so its output file is
// picked up for upload just like a build-command output.
if a.BuildCommand == "" && a.Type == config.ArtifactTarball && (len(a.Include) > 0 || a.Git != nil) {
cmdio.LogProgress(ctx, fmt.Sprintf("Building %s...", artifactName))
if err := buildTarballArtifact(ctx, b, artifactName, a); err != nil {
logdiag.LogError(ctx, err)
break
}
bundle.ApplyContext(ctx, b, expandGlobs{name: artifactName})
a = b.Config.Artifacts[artifactName]
if logdiag.HasError(ctx) {
break
}
}

if a.Type == "whl" && a.DynamicVersion && cacheDir != "" {
b.Metrics.AddBoolValue(metrics.ArtifactDynamicVersionIsSet, true)
for ind, artifactFile := range a.Files {
Expand Down
16 changes: 15 additions & 1 deletion bundle/artifacts/prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package artifacts
import (
"context"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
Expand Down Expand Up @@ -50,6 +51,17 @@ func (m *prepare) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics
b.Metrics.AddBoolValue(metrics.ArtifactBuildCommandIsSet, artifact.BuildCommand != "")
b.Metrics.AddBoolValue(metrics.ArtifactFilesIsSet, len(artifact.Files) != 0)

// A `tgz` artifact with `include`/`git` is built by DABs itself in the build
// phase (see artifacts.Build). `build` and `git`/`include` are mutually

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is semantics for include the same as in air cli? There it is relative to code source root, is include relative to bundle root in DABs? Can we make sure that is documented well somewhere?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

include is now relative to the code-source root, same as the air CLI. The reason I decided this is so that convert-to-dabs can map air CLI's include_paths straight across later.

// exclusive: either the user's command produces the tarball, or DABs does.
native := artifact.Type == config.ArtifactTarball && (len(artifact.Include) > 0 || artifact.Git != nil)
if native && artifact.BuildCommand != "" {
logdiag.LogError(ctx, fmt.Errorf("artifact %q: `build` cannot be combined with `git`/`include`", artifactName))
}
if native && len(artifact.Files) == 0 {
logdiag.LogError(ctx, fmt.Errorf("artifact %q: a tgz artifact needs a `files` entry naming the output path", artifactName))
}

l := b.Config.GetLocation("artifacts." + artifactName)
dirPath := filepath.Dir(l.File)

Expand Down Expand Up @@ -88,7 +100,9 @@ func (m *prepare) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics
logdiag.LogError(ctx, errors.New("misconfigured artifact: please specify 'build' or 'files' property"))
}

if len(artifact.Files) > 0 && artifact.BuildCommand == "" {
// Skip glob expansion for a DABs-built tgz: its output file does not exist yet
// (it is produced in the build phase, which expands globs afterward).
if len(artifact.Files) > 0 && artifact.BuildCommand == "" && !native {
bundle.ApplyContext(ctx, b, expandGlobs{name: artifactName})
}

Expand Down
160 changes: 160 additions & 0 deletions bundle/artifacts/tarball.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package artifacts

import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"slices"
"strings"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/mutator/aicode"
"github.com/databricks/cli/bundle/deploy/files"
"github.com/databricks/cli/libs/fileset"
libsync "github.com/databricks/cli/libs/sync"
)

// buildTarballArtifact produces the gzipped tarball for a `type: tgz` artifact that
// DABs builds itself (no user `build` command). Every entry nests under a single
// top-level directory named for the artifact's code-source root (`path`), matching the
// AI Runtime's /databricks/code_source/<dir> extraction contract — the same layout
// aicode (for a directory code_source_path) and the air CLI produce. With `git` set the
// tarball snapshots that ref; otherwise it packs the working tree scoped to `include`.
Comment thread
vinchenzo-db marked this conversation as resolved.
Outdated
func buildTarballArtifact(ctx context.Context, b *bundle.Bundle, name string, a *config.Artifact) error {
if len(a.Files) != 1 {
return fmt.Errorf("artifact %q: a tgz artifact needs exactly one `files` entry naming the output path", name)
}
out := a.Files[0].Source // made absolute by artifacts.Prepare

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the output path to be specified?

We can interpolate a temporary path, under .databricks for example, such that this file is never included in the archive it produces on the second run.

Artifacts are uploaded separately from regular files. The places that refer to artifacts do path rewriting to make sure the final path we use in the workspace is the right one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now I think this is still necessary. We can mark it as a follow up if you feel it shouldn't be, but right now the BYOT API specifies code_source_path, which is where the user runs their code. Without this output path I think it becomes a little opaque to the user that code_source_path would still say ./dist/code.tgz, but the artifact now lives somewhere else and nothing links them.

I asked claude what it would take to bridge this gap and we would need to let code_source_path reference the artifact by name, but alledgely its not a very easy fix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, yeah I agree with the opaque bit, and that referencing via interpolation is the right solution.

You'd get something like this:

code_source_path: ${artifacts.air_code_source.output}

if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}

// Write to a temp file and rename on success, so a failed build never leaves a
// partial tarball at `out`.
tmp, err := os.CreateTemp(filepath.Dir(out), filepath.Base(out)+".*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
renamed := false
defer func() {
tmp.Close() // harmless double-close after the success path; closes fd on error paths
if !renamed {
os.Remove(tmpName)
Comment thread
vinchenzo-db marked this conversation as resolved.
Outdated
}
}()

if a.Git != nil {
err = tarballFromGit(ctx, b, a, tmp)
} else {
err = tarballFromInclude(ctx, b, a, tmp)
}
if err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, out); err != nil {
return err
}
renamed = true
return nil
}

// codeRoot returns the artifact's code-source root as a sync-root-relative path plus
// its directory name. dirName is the load-bearing top-level entry the runtime extracts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load-bearing? 😂

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed, lol

// to /databricks/code_source/<dir>.
func codeRoot(b *bundle.Bundle, a *config.Artifact) (relBase, dirName string, err error) {
rel, err := filepath.Rel(b.SyncRootPath, a.Path)
if err != nil {
return "", "", fmt.Errorf("artifact path %q: %w", a.Path, err)
}
return filepath.ToSlash(rel), filepath.Base(a.Path), nil
}

// tarballFromInclude packs the working tree under the artifact's code-source root,
// optionally narrowed to `include` subpaths, using the bundle sync walker (filtering
// matches bundle file sync: .gitignore + sync.include/exclude). Entries are re-based
// under the code-source dir name via the shared aicode.BuildCodeSnapshot packer.
func tarballFromInclude(ctx context.Context, b *bundle.Bundle, a *config.Artifact, w io.Writer) error {
relBase, dirName, err := codeRoot(b, a)
if err != nil {
return err
}
list, err := includeFiles(ctx, b, relBase, a.Include)
if err != nil {
return err
}
if len(list) == 0 {
return fmt.Errorf("artifact tgz: no files to pack under %q (all excluded by .gitignore/sync.exclude, or empty)", a.Path)
}
_, err = aicode.BuildCodeSnapshot(b.SyncRoot, relBase, list, dirName, w)
Comment thread
vinchenzo-db marked this conversation as resolved.
Outdated
return err
}

// includeFiles lists the files to pack: the whole code-source root (relBase) when
// include is empty, else only the given subpaths (relative to relBase). The result is
// scoped to relBase so a force-added sync.include stray outside it is dropped.
func includeFiles(ctx context.Context, b *bundle.Bundle, relBase string, include []string) ([]fileset.File, error) {
opts, err := files.GetSyncOptions(ctx, b)
Comment thread
vinchenzo-db marked this conversation as resolved.
if err != nil {
return nil, err
}
var paths []string
if len(include) == 0 {
paths = []string{relBase}
} else {
for _, p := range include {
paths = append(paths, path.Join(relBase, p))
}
}
fl, err := libsync.NewFileList(ctx, opts.WorktreeRoot, opts.LocalRoot, paths, opts.Include, opts.Exclude)
if err != nil {
return nil, err
}
all, err := fl.Files(ctx)
if err != nil {
return nil, err
}
if relBase == "." {
return all, nil
}
prefix := relBase + "/"
return slices.DeleteFunc(all, func(f fileset.File) bool {
return !strings.HasPrefix(f.Relative, prefix)
}), nil
}

// tarballFromGit snapshots a git ref via `git archive`, nesting every entry under the
// code-source dir name (--prefix) so the archive matches the include/aicode layout.
// Commit wins over Branch. `include`, when set, scopes the archived pathspecs.
func tarballFromGit(ctx context.Context, b *bundle.Bundle, a *config.Artifact, w io.Writer) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have we tested equivalence of tarballs from here vs CLI? Note also the tarball changes that landed in CLI recently to respect gitignore

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made them share code instead of testing equivalence

ref := a.Git.Commit
if ref == "" {
ref = a.Git.Branch
}
if ref == "" {
return errors.New("git artifact: specify git.commit or git.branch")
}
dirName := filepath.Base(a.Path)
args := []string{"-C", b.SyncRootPath, "archive", "--format=tar.gz", "--prefix=" + dirName + "/", ref}
if len(a.Include) > 0 {
args = append(args, "--")
args = append(args, a.Include...)
}
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Stdout = w
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git archive %s: %w: %s", ref, err, stderr.String())
}
return nil
}
81 changes: 81 additions & 0 deletions bundle/artifacts/tarball_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package artifacts

import (
"archive/tar"
"bytes"
"compress/gzip"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// tarEntries reads a gzipped tarball and returns entry name -> content.
func tarEntries(t *testing.T, b []byte) map[string]string {
t.Helper()
gzr, err := gzip.NewReader(bytes.NewReader(b))
require.NoError(t, err)
tr := tar.NewReader(gzr)
out := map[string]string{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
require.NoError(t, err)
body, err := io.ReadAll(tr)
require.NoError(t, err)
out[hdr.Name] = string(body)
}
return out
}

func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v: %s", args, out)
}

// TestTarballFromGitPrefixesEntries verifies git mode nests every entry under the
// code-source dir name (the load-bearing top-level the runtime extracts to
// /databricks/code_source/<dir>), matching aicode and the air CLI.
func TestTarballFromGitPrefixesEntries(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init", "-q")
runGit(t, repo, "config", "user.email", "t@example.com")
runGit(t, repo, "config", "user.name", "t")
// Keep line endings verbatim so the content check doesn't depend on the runner's
// core.autocrlf (true by default on the Windows CI image).
runGit(t, repo, "config", "core.autocrlf", "false")
require.NoError(t, os.WriteFile(filepath.Join(repo, "train.py"), []byte("print('x')\n"), 0o644))
runGit(t, repo, "add", "-A")
runGit(t, repo, "commit", "-qm", "init")

b := &bundle.Bundle{SyncRootPath: repo}
a := &config.Artifact{Path: repo, Git: &config.ArtifactGit{Commit: "HEAD"}}

var buf bytes.Buffer
require.NoError(t, tarballFromGit(t.Context(), b, a, &buf))

// The entry nests under the code-source dir name (the load-bearing top-level).
dir := filepath.Base(repo)
entries := tarEntries(t, buf.Bytes())
require.Contains(t, entries, dir+"/train.py")
assert.Equal(t, "print('x')", strings.TrimSpace(entries[dir+"/train.py"]))
}

func TestTarballFromGitRequiresRef(t *testing.T) {
b := &bundle.Bundle{SyncRootPath: t.TempDir()}
a := &config.Artifact{Path: b.SyncRootPath, Git: &config.ArtifactGit{}}
err := tarballFromGit(t.Context(), b, a, io.Discard)
require.ErrorContains(t, err, "git.commit or git.branch")
}
25 changes: 25 additions & 0 deletions bundle/config/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,28 @@ const ArtifactPythonWheel ArtifactType = `whl`

const ArtifactJar ArtifactType = `jar`

// ArtifactTarball is a gzipped tar of source files, built by DABs itself from
// `include` paths and/or a `git` ref rather than by a user `build` command.
// Uploaded and referenced like any other artifact file (e.g. as an AI Runtime
// task's code_source_path).
const ArtifactTarball ArtifactType = `tgz`

// Values returns all valid ArtifactType values
func (ArtifactType) Values() []ArtifactType {
return []ArtifactType{
ArtifactPythonWheel,
ArtifactJar,
ArtifactTarball,
}
}

// ArtifactGit pins a `tgz` artifact to a git ref, so the tarball is a snapshot of
// that ref rather than of the working tree. Commit wins over Branch when both set.
type ArtifactGit struct {
Branch string `json:"branch,omitempty"`
Commit string `json:"commit,omitempty"`
}

type ArtifactFile struct {
Source string `json:"source"`

Expand All @@ -46,4 +60,15 @@ type Artifact struct {
Executable exec.ExecutableType `json:"executable,omitempty"`

DynamicVersion bool `json:"dynamic_version,omitempty"`

// Include narrows a `tgz` artifact to these subpaths of its code-source root
// (`path`), relative to that root; empty packs the whole root. Filtered like
// bundle file sync (.gitignore-honored). Entries nest under the root's directory
// name, matching the air CLI's include_paths and the runtime's
// /databricks/code_source/<dir> layout. Mutually exclusive with `build`.
Include []string `json:"include,omitempty"`

// Git, when set on a `tgz` artifact, snapshots the given ref instead of the
// working tree. Mutually exclusive with `build`.
Git *ArtifactGit `json:"git,omitempty"`
}
2 changes: 1 addition & 1 deletion bundle/config/mutator/aicode/package_code_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ func packageOne(ctx context.Context, b *bundle.Bundle, cs codeSource) (string, [
// Build the archive in memory so its content hash can name the file; the hash is
// computed while gzipping, so this adds no extra pass over the files.
var buf bytes.Buffer
sha, err := buildCodeSnapshot(b.SyncRoot, relBase, files, dirName, &buf)
sha, err := BuildCodeSnapshot(b.SyncRoot, relBase, files, dirName, &buf)
if err != nil {
return "", nil, fmt.Errorf("failed to package code_source_path %q: %w", cs.value, err)
}
Expand Down
4 changes: 2 additions & 2 deletions bundle/config/mutator/aicode/snapshot_package.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ var tarEpoch = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
// The AIR CLI excludes these; we match it so macOS archives carry no extra entries.
const appleDoublePrefix = "._"

// buildCodeSnapshot writes a reproducible gzipped tarball of the given files to out
// BuildCodeSnapshot writes a reproducible gzipped tarball of the given files to out
// and returns its SHA-256 hex digest. syncRoot is the root the files' Relative paths
// are against (the bundle sync root); relBase is the code directory relative to that
// root; prefix is the archive's top-level directory name. Each file at
Expand All @@ -40,7 +40,7 @@ const appleDoublePrefix = "._"
// The file list is produced by the bundle's sync walker, so it honors .gitignore
// (including nested files) and the top-level sync.include/exclude globs — the same
// filtering as bundle file sync.
func buildCodeSnapshot(syncRoot vfs.Path, relBase string, files []fileset.File, prefix string, out io.Writer) (string, error) {
func BuildCodeSnapshot(syncRoot vfs.Path, relBase string, files []fileset.File, prefix string, out io.Writer) (string, error) {
// Sort by relative path so the archive byte stream (and thus its hash) does not
// depend on iteration order.
slices.SortFunc(files, func(a, b fileset.File) int {
Expand Down
Loading
Loading