databricks-cli/cmd/labs/project/project.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

355 lines
8.9 KiB
Go
Raw Normal View History

package project
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/databricks/cli/cmd/labs/github"
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/log"
"github.com/databricks/cli/libs/python"
"github.com/databricks/databricks-sdk-go/logger"
"github.com/fatih/color"
"gopkg.in/yaml.v3"
"github.com/spf13/cobra"
)
const ownerRW = 0o600
func Load(ctx context.Context, labsYml string) (*Project, error) {
raw, err := os.ReadFile(labsYml)
if err != nil {
return nil, fmt.Errorf("read labs.yml: %w", err)
}
project, err := readFromBytes(ctx, raw)
if err != nil {
return nil, err
}
project.folder = filepath.Dir(labsYml)
return project, nil
}
func readFromBytes(ctx context.Context, labsYmlRaw []byte) (*Project, error) {
var project Project
err := yaml.Unmarshal(labsYmlRaw, &project)
if err != nil {
return nil, fmt.Errorf("parse labs.yml: %w", err)
}
e := (&project).metaEntrypoint(ctx)
if project.Installer != nil {
project.Installer.Entrypoint = e
}
if project.Uninstaller != nil {
project.Uninstaller.Entrypoint = e
}
rootDir, err := PathInLabs(ctx, project.Name)
if err != nil {
return nil, err
}
project.rootDir = rootDir
return &project, nil
}
type Project struct {
SpecVersion int `yaml:"$version"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Installer *hook `yaml:"install,omitempty"`
Uninstaller *hook `yaml:"uninstall,omitempty"`
Main string `yaml:"entrypoint"`
MinPython string `yaml:"min_python"`
Commands []*proxy `yaml:"commands,omitempty"`
folder string
rootDir string
}
func (p *Project) IsZipball() bool {
// the simplest way of running the project - download ZIP file from github
return true
}
func (p *Project) HasPython() bool {
if strings.HasSuffix(p.Main, ".py") {
return true
}
if p.Installer != nil && p.Installer.HasPython() {
return true
}
if p.Uninstaller != nil && p.Uninstaller.HasPython() {
return true
}
return p.MinPython != ""
}
func (p *Project) metaEntrypoint(ctx context.Context) *Entrypoint {
return &Entrypoint{
Project: p,
RequireRunningCluster: p.requireRunningCluster(),
}
}
func (p *Project) requireRunningCluster() bool {
if p.Installer != nil && p.Installer.RequireRunningCluster() {
return true
}
for _, v := range p.Commands {
if v.RequireRunningCluster {
return true
}
}
return false
}
func (p *Project) fileExists(name string) bool {
_, err := os.Stat(name)
return err == nil
}
func (p *Project) projectFilePath(name string) string {
return filepath.Join(p.EffectiveLibDir(), name)
}
func (p *Project) IsPythonProject() bool {
if p.fileExists(p.projectFilePath("setup.py")) {
return true
}
if p.fileExists(p.projectFilePath("pyproject.toml")) {
return true
}
return false
}
func (p *Project) IsDeveloperMode() bool {
return p.folder != "" && !strings.HasPrefix(p.LibDir(), p.folder)
}
func (p *Project) HasFolder() bool {
return p.folder != ""
}
func (p *Project) HasAccountLevelCommands() bool {
for _, v := range p.Commands {
if v.IsAccountLevel {
return true
}
}
return false
}
func (p *Project) IsBundleAware() bool {
for _, v := range p.Commands {
if v.IsBundleAware {
return true
}
}
return false
}
func (p *Project) Register(parent *cobra.Command) {
group := &cobra.Command{
Use: p.Name,
Short: p.Description,
GroupID: "labs",
}
parent.AddCommand(group)
for _, cp := range p.Commands {
cp.register(group)
cp.Entrypoint.Project = p
}
}
func (p *Project) CacheDir() string {
return filepath.Join(p.rootDir, "cache")
}
func (p *Project) ConfigDir() string {
return filepath.Join(p.rootDir, "config")
}
func (p *Project) LibDir() string {
return filepath.Join(p.rootDir, "lib")
}
func (p *Project) EffectiveLibDir() string {
if p.IsDeveloperMode() {
// developer is working on a local checkout, that is not inside of installed root
return p.folder
}
return p.LibDir()
}
func (p *Project) StateDir() string {
return filepath.Join(p.rootDir, "state")
}
func (p *Project) EnsureFoldersExist() error {
dirs := []string{p.CacheDir(), p.ConfigDir(), p.LibDir(), p.StateDir()}
for _, v := range dirs {
err := os.MkdirAll(v, ownerRWXworldRX)
if err != nil {
return fmt.Errorf("folder %s: %w", v, err)
}
}
return nil
}
func (p *Project) Uninstall(cmd *cobra.Command) error {
if p.Uninstaller != nil {
err := p.Uninstaller.runHook(cmd)
if err != nil {
return fmt.Errorf("uninstall hook: %w", err)
}
}
ctx := cmd.Context()
log.Infof(ctx, "Removing project: %s", p.Name)
return os.RemoveAll(p.rootDir)
}
func (p *Project) virtualEnvPath(ctx context.Context) string {
if p.IsDeveloperMode() {
// When a virtual environment has been activated, the VIRTUAL_ENV environment variable
// is set to the path of the environment. Since explicitly activating a virtual environment
// is not required to use it, VIRTUAL_ENV cannot be relied upon to determine whether a virtual
// environment is being used.
//
// See https://docs.python.org/3/library/venv.html#how-venvs-work
activatedVenv := env.Get(ctx, "VIRTUAL_ENV")
if activatedVenv != "" {
logger.Debugf(ctx, "(development mode) using active virtual environment from: %s", activatedVenv)
return activatedVenv
}
nonActivatedVenv, err := python.DetectVirtualEnvPath(p.EffectiveLibDir())
if err == nil {
logger.Debugf(ctx, "(development mode) using virtual environment from: %s", nonActivatedVenv)
return nonActivatedVenv
}
}
// by default, we pick Virtual Environment from DATABRICKS_LABS_STATE_DIR
return filepath.Join(p.StateDir(), "venv")
}
func (p *Project) virtualEnvPython(ctx context.Context) string {
overridePython := env.Get(ctx, "PYTHON_BIN")
if overridePython != "" {
return overridePython
}
if runtime.GOOS == "windows" {
return filepath.Join(p.virtualEnvPath(ctx), "Scripts", "python.exe")
}
return filepath.Join(p.virtualEnvPath(ctx), "bin", "python3")
}
func (p *Project) loginFile(ctx context.Context) string {
if p.IsDeveloperMode() {
// developers may not want to pollute the state in
// ~/.databricks/labs/X/config while the version is not yet
// released
return p.projectFilePath(".databricks-login.json")
}
return filepath.Join(p.ConfigDir(), "login.json")
}
func (p *Project) loadLoginConfig(ctx context.Context) (*loginConfig, error) {
loginFile := p.loginFile(ctx)
log.Debugf(ctx, "Loading login configuration from: %s", loginFile)
lc, err := tryLoadAndParseJSON[loginConfig](loginFile)
if err != nil {
return nil, fmt.Errorf("try load: %w", err)
}
lc.Entrypoint = p.metaEntrypoint(ctx)
return lc, nil
}
func (p *Project) versionFile(ctx context.Context) string {
return filepath.Join(p.StateDir(), "version.json")
}
func (p *Project) InstalledVersion(ctx context.Context) (*version, error) {
if p.IsDeveloperMode() {
return &version{
Version: "*",
Date: time.Now(),
}, nil
}
versionFile := p.versionFile(ctx)
log.Debugf(ctx, "Loading installed version info from: %s", versionFile)
return tryLoadAndParseJSON[version](versionFile)
}
func (p *Project) writeVersionFile(ctx context.Context, ver string) error {
versionFile := p.versionFile(ctx)
raw, err := json.Marshal(version{
Version: ver,
Date: time.Now(),
})
if err != nil {
return err
}
log.Debugf(ctx, "Writing installed version info to: %s", versionFile)
return os.WriteFile(versionFile, raw, ownerRW)
}
// checkUpdates is called before every command of an installed project,
// giving users hints when they need to update their installations.
func (p *Project) checkUpdates(cmd *cobra.Command) error {
ctx := cmd.Context()
if p.IsDeveloperMode() {
// skipping update check for projects in developer mode, that
// might not be installed yet
return nil
}
Enable offline install of labs projects (#2049) ## Changes <!-- Summary of your changes that are easy to understand --> This PR makes changes to the labs code base to allow for offline installation of labs projects (like UCX). By passing a flag --offline=true, the code will skip checking for project versions and download code from GitHub and instead will look from the local installation folder. This cmd is useful in systems where there is internet restriction, the user should follow a set-up as follows: - install a labs project on a machine which has internet - zip and copy the file to the intended machine and - run databricks labs install <project name>--offline=true it will look for the code in the same install directory and if present load from there. Closes #1646 related to https://github.com/databrickslabs/ucx/issues/3418 ## Tests <!-- How is this tested? --> Added unit test case and tested. NO_CHANGELOG=true --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Pieter Noordhuis <pieter.noordhuis@databricks.com> Co-authored-by: Lennart Kats (databricks) <lennart.kats@databricks.com> Co-authored-by: Denis Bilenko <denis.bilenko@databricks.com> Co-authored-by: Julia Crawford (Databricks) <julia.crawford@databricks.com> Co-authored-by: Ilya Kuznetsov <ilya.kuznetsov@databricks.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Andrew Nester <andrew.nester@databricks.com> Co-authored-by: Anton Nekipelov <226657+anton-107@users.noreply.github.com> Co-authored-by: shreyas-goenka <88374338+shreyas-goenka@users.noreply.github.com>
2025-03-10 10:01:17 +00:00
r := github.NewReleaseCache("databrickslabs", p.Name, p.CacheDir(), false)
versions, err := r.Load(ctx)
if err != nil {
return err
}
installed, err := p.InstalledVersion(ctx)
if err != nil {
return err
}
latest := versions[0]
if installed.Version == latest.Version {
return nil
}
ago := time.Since(latest.PublishedAt)
msg := "[UPGRADE ADVISED] Newer %s version was released %s ago. Please run `databricks labs upgrade %s` to upgrade: %s -> %s"
cmd.PrintErrln(color.YellowString(msg, p.Name, p.timeAgo(ago), p.Name, installed.Version, latest.Version))
return nil
}
func (p *Project) timeAgo(dur time.Duration) string {
days := int(dur.Hours()) / 24
hours := int(dur.Hours()) % 24
minutes := int(dur.Minutes()) % 60
if dur < time.Minute {
return "minute"
} else if dur < time.Hour {
return fmt.Sprintf("%d minutes", minutes)
} else if dur < (24 * time.Hour) {
return fmt.Sprintf("%d hours", hours)
}
return fmt.Sprintf("%d days", days)
}
func (p *Project) profileOverride(cmd *cobra.Command) string {
profileFlag := cmd.Flag("profile")
if profileFlag == nil {
return ""
}
return profileFlag.Value.String()
}
type version struct {
Version string `json:"version"`
Date time.Time `json:"date"`
}