Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show actionable errors for collaborative deployment scenarios #1386

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2cea265
Diagnostics for collaborative deployment scenarios
lennartkats-db Apr 19, 2024
7cdfc8d
Merge remote-tracking branch 'databricks/main' into cp-better-errors
lennartkats-db Apr 25, 2024
fc07725
Merge remote-tracking branch 'databricks/main' into cp-better-errors
lennartkats-db Jun 2, 2024
4aa38b8
Add missing file
lennartkats-db Jun 2, 2024
c46ecde
Add Lakehouse monitoring
lennartkats-db Jun 2, 2024
0b9feab
Cleanup
lennartkats-db Jun 2, 2024
7fff4bc
Cleanup
lennartkats-db Jul 6, 2024
c222ba3
Refactor
lennartkats-db Jul 6, 2024
b384b36
Merge remote-tracking branch 'databricks/main' into cp-better-errors
lennartkats-db Jul 6, 2024
3ba3c17
Address reviewer comments
lennartkats-db Jul 28, 2024
9981f07
Cleanup
lennartkats-db Jul 31, 2024
5ed6fc4
Merge remote-tracking branch 'databricks/main' into cp-better-errors
lennartkats-db Aug 22, 2024
26c3b42
Restore original 404 handling logic
lennartkats-db Aug 22, 2024
f93d394
Harden code with deeper tests
lennartkats-db Aug 22, 2024
a237dfa
Fix linter error
lennartkats-db Aug 22, 2024
49598cc
Fix copy/paste error
lennartkats-db Aug 22, 2024
8abae34
Update comment
lennartkats-db Sep 9, 2024
b11917e
Fix path
lennartkats-db Sep 9, 2024
4c06a34
Cleanup
lennartkats-db Sep 9, 2024
e533dd0
Extract function to separate module
lennartkats-db Sep 9, 2024
3b33d8c
Add comment
lennartkats-db Sep 9, 2024
bfe36fc
Rework error now that we can no longer detect permission errors
lennartkats-db Sep 12, 2024
bde209c
Process PR feedback
lennartkats-db Sep 22, 2024
a39457e
Show email addresses for regular users
lennartkats-db Sep 23, 2024
c850cfb
Merge remote-tracking branch 'databricks/main' into cp-better-errors
lennartkats-db Sep 23, 2024
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
101 changes: 48 additions & 53 deletions bundle/config/mutator/run_as.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,50 +30,44 @@ func (m *setRunAs) Name() string {
return "SetRunAs"
}

type errUnsupportedResourceTypeForRunAs struct {
resourceType string
resourceLocation dyn.Location
currentUser string
runAsUser string
func reportRunAsNotSupported(resourceType string, location dyn.Location, currentUser string, runAsUser string) diag.Diagnostics {
return diag.Diagnostics{{
Summary: fmt.Sprintf("%s do not support a setting a run_as user that is different from the owner.\n"+
lennartkats-db marked this conversation as resolved.
Show resolved Hide resolved
"Current identity: %s. Run as identity: %s.\n"+
"See https://docs.databricks.com/dev-tools/bundles/run-as.html to learn more about the run_as property.", resourceType, currentUser, runAsUser),
Locations: []dyn.Location{location},
Severity: diag.Error,
}}
}

func (e errUnsupportedResourceTypeForRunAs) Error() string {
return fmt.Sprintf("%s are not supported when the current deployment user is different from the bundle's run_as identity. Please deploy as the run_as identity. Please refer to the documentation at https://docs.databricks.com/dev-tools/bundles/run-as.html for more details. Location of the unsupported resource: %s. Current identity: %s. Run as identity: %s", e.resourceType, e.resourceLocation, e.currentUser, e.runAsUser)
}

type errBothSpAndUserSpecified struct {
spName string
spLoc dyn.Location
userName string
userLoc dyn.Location
}
func validateRunAs(b *bundle.Bundle) diag.Diagnostics {
diags := diag.Diagnostics{}

func (e errBothSpAndUserSpecified) Error() string {
return fmt.Sprintf("run_as section must specify exactly one identity. A service_principal_name %q is specified at %s. A user_name %q is defined at %s", e.spName, e.spLoc, e.userName, e.userLoc)
}
neitherSpecifiedErr := diag.Diagnostics{{
Summary: "run_as section must specify exactly one identity. Neither service_principal_name nor user_name is specified",
Locations: []dyn.Location{b.Config.GetLocation("run_as")},
Severity: diag.Error,
}}

func validateRunAs(b *bundle.Bundle) error {
neitherSpecifiedErr := fmt.Errorf("run_as section must specify exactly one identity. Neither service_principal_name nor user_name is specified at %s", b.Config.GetLocation("run_as"))
// Error if neither service_principal_name nor user_name are specified, but the
// Fail fast if neither service_principal_name nor user_name are specified, but the
// run_as section is present.
if b.Config.Value().Get("run_as").Kind() == dyn.KindNil {
return neitherSpecifiedErr
}
// Error if one or both of service_principal_name and user_name are specified,

// Fail fast if one or both of service_principal_name and user_name are specified,
// but with empty values.
if b.Config.RunAs.ServicePrincipalName == "" && b.Config.RunAs.UserName == "" {
runAs := b.Config.RunAs
if runAs.ServicePrincipalName == "" && runAs.UserName == "" {
return neitherSpecifiedErr
}

// Error if both service_principal_name and user_name are specified
runAs := b.Config.RunAs
if runAs.UserName != "" && runAs.ServicePrincipalName != "" {
return errBothSpAndUserSpecified{
spName: runAs.ServicePrincipalName,
userName: runAs.UserName,
spLoc: b.Config.GetLocation("run_as.service_principal_name"),
userLoc: b.Config.GetLocation("run_as.user_name"),
}
diags = diags.Extend(diag.Diagnostics{{
Summary: "run_as section cannot specify both user_name and service_principal_name",
Locations: []dyn.Location{b.Config.GetLocation("run_as")},
Severity: diag.Error,
}})
}

identity := runAs.ServicePrincipalName
Expand All @@ -83,40 +77,40 @@ func validateRunAs(b *bundle.Bundle) error {

// All resources are supported if the run_as identity is the same as the current deployment identity.
if identity == b.Config.Workspace.CurrentUser.UserName {
return nil
return diags
}

// DLT pipelines do not support run_as in the API.
if len(b.Config.Resources.Pipelines) > 0 {
return errUnsupportedResourceTypeForRunAs{
resourceType: "pipelines",
resourceLocation: b.Config.GetLocation("resources.pipelines"),
currentUser: b.Config.Workspace.CurrentUser.UserName,
runAsUser: identity,
}
diags = diags.Extend(reportRunAsNotSupported(
"pipelines",
b.Config.GetLocation("resources.pipelines"),
b.Config.Workspace.CurrentUser.UserName,
identity,
))
}

// Model serving endpoints do not support run_as in the API.
if len(b.Config.Resources.ModelServingEndpoints) > 0 {
return errUnsupportedResourceTypeForRunAs{
resourceType: "model_serving_endpoints",
resourceLocation: b.Config.GetLocation("resources.model_serving_endpoints"),
currentUser: b.Config.Workspace.CurrentUser.UserName,
runAsUser: identity,
}
diags = diags.Extend(reportRunAsNotSupported(
"model_serving_endpoints",
b.Config.GetLocation("resources.model_serving_endpoints"),
b.Config.Workspace.CurrentUser.UserName,
identity,
))
}

// Monitors do not support run_as in the API.
if len(b.Config.Resources.QualityMonitors) > 0 {
return errUnsupportedResourceTypeForRunAs{
resourceType: "quality_monitors",
resourceLocation: b.Config.GetLocation("resources.quality_monitors"),
currentUser: b.Config.Workspace.CurrentUser.UserName,
runAsUser: identity,
}
diags = diags.Extend(reportRunAsNotSupported(
"quality_monitors",
b.Config.GetLocation("resources.quality_monitors"),
b.Config.Workspace.CurrentUser.UserName,
identity,
))
}

return nil
return diags
}

func setRunAsForJobs(b *bundle.Bundle) {
Expand Down Expand Up @@ -187,8 +181,9 @@ func (m *setRunAs) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics {
}

// Assert the run_as configuration is valid in the context of the bundle
if err := validateRunAs(b); err != nil {
return diag.FromErr(err)
diags := validateRunAs(b)
if diags.HasError() {
return diags
}
pietern marked this conversation as resolved.
Show resolved Hide resolved

setRunAsForJobs(b)
Expand Down
9 changes: 3 additions & 6 deletions bundle/config/mutator/run_as_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,8 @@ func TestRunAsErrorForUnsupportedResources(t *testing.T) {
Config: *r,
}
diags := bundle.Apply(context.Background(), b, SetRunAs())
assert.Equal(t, diags.Error().Error(), errUnsupportedResourceTypeForRunAs{
resourceType: rt,
resourceLocation: dyn.Location{},
currentUser: "alice",
runAsUser: "bob",
}.Error(), "expected run_as with a different identity than the current deployment user to not supported for resources of type: %s", rt)
assert.Contains(t, diags.Error().Error(), "do not support a setting a run_as user that is different from the owner.\n"+
"Current identity: alice. Run as identity: bob.\n"+
"See https://docs.databricks.com/dev-tools/bundles/run-as.html to learn more about the run_as property.", rt)
}
}
6 changes: 6 additions & 0 deletions bundle/deploy/files/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package files

import (
"context"
"errors"
"fmt"
"io/fs"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/permissions"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/log"
Expand All @@ -25,6 +28,9 @@ func (m *upload) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {

b.Files, err = sync.RunOnce(ctx)
if err != nil {
if errors.Is(err, fs.ErrPermission) {
return permissions.ReportPossiblePermissionDenied(ctx, b, b.Config.Workspace.FilePath)
}
lennartkats-db marked this conversation as resolved.
Show resolved Hide resolved
return diag.FromErr(err)
}

Expand Down
9 changes: 8 additions & 1 deletion bundle/deploy/lock/acquire.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package lock
import (
"context"
"errors"
"io/fs"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/permissions"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/filer"
"github.com/databricks/cli/libs/locker"
Expand Down Expand Up @@ -51,12 +53,17 @@ func (m *acquire) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics
if err != nil {
log.Errorf(ctx, "Failed to acquire deployment lock: %v", err)

if errors.Is(err, fs.ErrPermission) {
return permissions.ReportPossiblePermissionDenied(ctx, b, b.Config.Workspace.StatePath)
}

notExistsError := filer.NoSuchDirectoryError{}
if errors.As(err, &notExistsError) {
// If we get a "doesn't exist" error from the API this indicates
// we either don't have permissions or the path is invalid.
return diag.Errorf("cannot write to deployment root (this can indicate a previous deploy was done with a different identity): %s", b.Config.Workspace.RootPath)
return permissions.ReportPossiblePermissionDenied(ctx, b, b.Config.Workspace.StatePath)
}
lennartkats-db marked this conversation as resolved.
Show resolved Hide resolved

return diag.FromErr(err)
}

Expand Down
5 changes: 5 additions & 0 deletions bundle/deploy/terraform/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/permissions"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/log"
"github.com/hashicorp/terraform-exec/tfexec"
Expand Down Expand Up @@ -34,6 +35,10 @@ func (w *apply) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
// Apply terraform according to the computed plan
err := tf.Apply(ctx, tfexec.DirOrPlan(b.Plan.Path))
if err != nil {
diags := permissions.TryExtendTerraformPermissionError(ctx, b, err)
if diags != nil {
return diags
}
return diag.Errorf("terraform apply: %v", err)
}

Expand Down
1 change: 1 addition & 0 deletions bundle/permissions/mutator.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
const CAN_MANAGE = "CAN_MANAGE"
const CAN_VIEW = "CAN_VIEW"
const CAN_RUN = "CAN_RUN"
const IS_OWNER = "IS_OWNER"
lennartkats-db marked this conversation as resolved.
Show resolved Hide resolved

var allowedLevels = []string{CAN_MANAGE, CAN_VIEW, CAN_RUN}
var levelsMap = map[string](map[string]string){
Expand Down
107 changes: 107 additions & 0 deletions bundle/permissions/permission_diagnostics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package permissions

import (
"context"
"fmt"
"strings"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/set"
)

type permissionDiagnostics struct{}

func PermissionDiagnostics() bundle.Mutator {
return &permissionDiagnostics{}
}

func (m *permissionDiagnostics) Name() string {
return "CheckPermissions"
}
pietern marked this conversation as resolved.
Show resolved Hide resolved

func (m *permissionDiagnostics) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
if len(b.Config.Permissions) == 0 {
// Only warn if there is an explicit top-level permissions section
return nil
}

canManageBundle, _ := analyzeBundlePermissions(b)
if canManageBundle {
return nil
}

return diag.Diagnostics{{
Severity: diag.Warning,
Summary: fmt.Sprintf("permissions section should include %s or one of their groups with CAN_MANAGE permissions", b.Config.Workspace.CurrentUser.UserName),
Locations: []dyn.Location{b.Config.GetLocation("permissions")},
ID: diag.PermissionNotIncluded,
}}
}

// analyzeBundlePermissions analyzes the top-level permissions of the bundle.
// This permission set is important since it determines the permissions of the
// target workspace folder.
//
// Returns:
// - isManager: true if the current user is can manage the bundle resources.
// - assistance: advice on who to contact as to manage this project
func analyzeBundlePermissions(b *bundle.Bundle) (bool, string) {
canManageBundle := false
otherManagers := set.NewSet[string]()
if b.Config.RunAs != nil && b.Config.RunAs.UserName != "" && b.Config.RunAs.UserName != b.Config.Workspace.CurrentUser.UserName {
// The run_as user is another human that could be contacted
// about this bundle.
otherManagers.Add(b.Config.RunAs.UserName)
}

currentUser := b.Config.Workspace.CurrentUser.UserName
targetPermissions := b.Config.Permissions
for _, p := range targetPermissions {
if p.Level != CAN_MANAGE {
continue
}

if p.UserName == currentUser || p.ServicePrincipalName == currentUser {
canManageBundle = true
continue
}

if isGroupOfCurrentUser(b, p.GroupName) {
canManageBundle = true
continue
}

// Permission doesn't apply to current user; add to otherManagers
otherManager := p.UserName
if otherManager == "" {
otherManager = p.GroupName
}
if otherManager == "" {
// Skip service principals
continue
}
otherManagers.Add(otherManager)
}
pietern marked this conversation as resolved.
Show resolved Hide resolved

assistance := "For assistance, contact the owners of this project."
if otherManagers.Size() > 0 {
assistance = fmt.Sprintf(
"For assistance, users or groups with appropriate permissions may include: %s.",
strings.Join(otherManagers.Values(), ", "),
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not sorted and is the cause of the test assertion failure in the latest test run.

)
}
return canManageBundle, assistance
}

func isGroupOfCurrentUser(b *bundle.Bundle, groupName string) bool {
currentUserGroups := b.Config.Workspace.CurrentUser.User.Groups

for _, g := range currentUserGroups {
if g.Display == groupName {
return true
}
}
return false
}
Loading