Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
20 changes: 13 additions & 7 deletions pkg/cmd/dev/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ import (
)

const (
crossFlag = "cross"
nogoDisableFlag = "--//build/toolchains:nogo_disable_flag"
geosTarget = "//c-deps:libgeos"
devTarget = "//pkg/cmd/dev:dev"
crossFlag = "cross"
cockroachTargetOss = "//pkg/cmd/cockroach-oss:cockroach-oss"
cockroachTarget = "//pkg/cmd/cockroach:cockroach"
nogoDisableFlag = "--//build/toolchains:nogo_disable_flag"
geosTarget = "//c-deps:libgeos"
devTarget = "//pkg/cmd/dev:dev"
)

type buildTarget struct {
Expand Down Expand Up @@ -77,9 +79,9 @@ var buildTargetMapping = map[string]string{
"bazel-remote": bazelRemoteTarget,
"buildifier": "@com_github_bazelbuild_buildtools//buildifier:buildifier",
"buildozer": "@com_github_bazelbuild_buildtools//buildozer:buildozer",
"cockroach": "//pkg/cmd/cockroach:cockroach",
"cockroach": cockroachTarget,
"cockroach-sql": "//pkg/cmd/cockroach-sql:cockroach-sql",
"cockroach-oss": "//pkg/cmd/cockroach-oss:cockroach-oss",
"cockroach-oss": cockroachTargetOss,
"cockroach-short": "//pkg/cmd/cockroach-short:cockroach-short",
"crlfmt": "@com_github_cockroachdb_crlfmt//:crlfmt",
"dev": devTarget,
Expand All @@ -95,7 +97,7 @@ var buildTargetMapping = map[string]string{
"obsservice": "//pkg/obsservice/cmd/obsservice:obsservice",
"optgen": "//pkg/sql/opt/optgen/cmd/optgen:optgen",
"optfmt": "//pkg/sql/opt/optgen/cmd/optfmt:optfmt",
"oss": "//pkg/cmd/cockroach-oss:cockroach-oss",
"oss": cockroachTargetOss,
"reduce": "//pkg/cmd/reduce:reduce",
"roachprod": "//pkg/cmd/roachprod:roachprod",
"roachprod-stress": "//pkg/cmd/roachprod-stress:roachprod-stress",
Expand Down Expand Up @@ -173,6 +175,10 @@ func (d *dev) build(cmd *cobra.Command, commandLine []string) error {
configArgs := getConfigArgs(args)
configArgs = append(configArgs, getConfigArgs(additionalBazelArgs)...)

if err := d.assertNoLinkedNpmDeps(buildTargets); err != nil {
return err
}

if cross == "" {
// Do not log --build_event_binary_file=... because it is not relevant to the actual call
// from the user perspective.
Expand Down
28 changes: 28 additions & 0 deletions pkg/cmd/dev/io/os/os.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,34 @@ func (o *OS) Readlink(filename string) (string, error) {
})
}

// ReadDir is a thin wrapper around os.ReadDir, which returns the names of files
// or directories within dirname.
func (o *OS) ReadDir(dirname string) ([]string, error) {
command := fmt.Sprintf("ls %s", dirname)
if !o.knobs.silent {
o.logger.Print(command)
}

output, err := o.Next(command, func() (string, error) {
var ret []string
entries, err := os.ReadDir(dirname)
if err != nil {
return "", err
}

for _, entry := range entries {
ret = append(ret, entry.Name())
}

return fmt.Sprintf("%s\n", strings.Join(ret, "\n")), nil
})

if err != nil {
return nil, err
}
return strings.Split(strings.TrimSpace(output), "\n"), nil
}

// IsDir wraps around os.Stat, which returns the os.FileInfo of the named
// directory. IsDir returns true if and only if it is an existing directory.
// If there is an error, it will be of type *PathError.
Expand Down
8 changes: 8 additions & 0 deletions pkg/cmd/dev/testdata/datadriven/dev-build
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ cp sandbox/pkg/cmd/cockroach-short/cockroach-short_/cockroach-short crdb-checkou
exec
dev build -- --verbose_failures --sandbox_debug
----
bazel info workspace --color=no
ls crdb-checkout/pkg/ui/node_modules/@cockroachlabs
ls crdb-checkout/pkg/ui/workspaces/eslint-plugin-crdb/node_modules/@cockroachlabs
ls crdb-checkout/pkg/ui/workspaces/db-console/src/js/node_modules/@cockroachlabs
ls crdb-checkout/pkg/ui/workspaces/db-console/ccl/src/js/node_modules/@cockroachlabs
ls crdb-checkout/pkg/ui/workspaces/cluster-ui/node_modules/@cockroachlabs
ls crdb-checkout/pkg/ui/workspaces/db-console/node_modules/@cockroachlabs
ls crdb-checkout/pkg/ui/workspaces/e2e-tests/node_modules/@cockroachlabs
bazel build //pkg/cmd/cockroach:cockroach --verbose_failures --sandbox_debug --build_event_binary_file=/tmp/path
bazel info workspace --color=no
mkdir crdb-checkout/bin
Expand Down
163 changes: 163 additions & 0 deletions pkg/cmd/dev/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
package main

import (
"errors"
"fmt"
"log"
"net/url"
Expand Down Expand Up @@ -48,6 +49,8 @@ func makeUICmd(d *dev) *cobra.Command {

// UIDirectories contains the absolute path to the root of each UI sub-project.
type UIDirectories struct {
workspace string
// workspace is the absolute path to ./ .
// root is the absolute path to ./pkg/ui.
root string
// clusterUI is the absolute path to ./pkg/ui/workspaces/cluster-ui.
Expand All @@ -58,6 +61,10 @@ type UIDirectories struct {
e2eTests string
// eslintPlugin is the absolute path to ./pkg/ui/workspaces/eslint-plugin-crdb.
eslintPlugin string
// protoOss is the absolute path to ./pkg/ui/workspaces/db-console/src/js/.
protoOss string
// protoCcl is the absolute path to ./pkg/ui/workspaces/db-console/ccl/src/js/.
protoCcl string
}

// getUIDirs computes the absolute path to the root of each UI sub-project.
Expand All @@ -68,14 +75,170 @@ func getUIDirs(d *dev) (*UIDirectories, error) {
}

return &UIDirectories{
workspace: workspace,
root: filepath.Join(workspace, "./pkg/ui"),
clusterUI: filepath.Join(workspace, "./pkg/ui/workspaces/cluster-ui"),
dbConsole: filepath.Join(workspace, "./pkg/ui/workspaces/db-console"),
e2eTests: filepath.Join(workspace, "./pkg/ui/workspaces/e2e-tests"),
eslintPlugin: filepath.Join(workspace, "./pkg/ui/workspaces/eslint-plugin-crdb"),
protoOss: filepath.Join(workspace, "./pkg/ui/workspaces/db-console/src/js"),
protoCcl: filepath.Join(workspace, "./pkg/ui/workspaces/db-console/ccl/src/js"),
}, nil
}

// assertNoLinkedNpmDeps looks for JS packages linked outside the Bazel
// workspace (typically via `pnpm link`). It returns an error if:
//
// 'targets' contains a Bazel target that requires the web UI
// AND
// a node_modules/ tree exists within pkg/ui (or its subtrees)
// AND
// a @cockroachlabs-scoped package is symlinked to an external directory
//
// (or if any error occurs while performing one of those checks).
func (d *dev) assertNoLinkedNpmDeps(targets []buildTarget) error {
uiWillBeBuilt := false
for _, target := range targets {
// TODO: This could potentially be a bazel query, e.g.
// 'somepath(${target.fullName}, //pkg/ui/workspaces/db-console:*)' or
// similar, but with only two eligible targets it doesn't seem quite
// worth it.
if target.fullName == cockroachTarget || target.fullName == cockroachTargetOss {
uiWillBeBuilt = true
break
}
}
if !uiWillBeBuilt {
// If no UI build is required, the presence of an externally-linked
// package doesn't matter.
return nil
}

// Find the current workspace and build some relevant absolute paths.
uiDirs, err := getUIDirs(d)
if err != nil {
return fmt.Errorf("could not check for linked NPM dependencies: %w", err)
}

jsPkgRoots := []string{
uiDirs.root,
uiDirs.eslintPlugin,
uiDirs.protoOss,
uiDirs.protoCcl,
uiDirs.clusterUI,
uiDirs.dbConsole,
uiDirs.e2eTests,
}

type LinkedPackage struct {
name string
dir string
}

anyPackageEscapesWorkspace := false

// Check for symlinks in each package's node_modules/@cockroachlabs/ dir.
for _, jsPkgRoot := range jsPkgRoots {
crlModulesPath := filepath.Join(jsPkgRoot, "node_modules/@cockroachlabs")
crlDeps, err := d.os.ReadDir(crlModulesPath)

// If node_modules/@cockroachlabs doesn't exist, it's likely that JS
// dependencies haven't been installed outside the Bazel workspace.
// This is expected for non-UI devs, and is a safe state.
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return fmt.Errorf("could not @cockroachlabs/ packages: %w", err)
}

linkedPackages := []LinkedPackage{}

// For each dependency in node_modules/@cockroachlabs/ ...
for _, depName := range crlDeps {
// Ignore empty strings, which are produced by d.os.ReadDir in
// dry-run mode.
if depName == "" {
continue
}

// Resolve the possible symlink.
depPath := filepath.Join(crlModulesPath, depName)
resolved, err := d.os.Readlink(depPath)
if err != nil {
return fmt.Errorf("could not evaluate symlink %s: %w", depPath, err)
Copy link
Collaborator

@rickystewart rickystewart Jul 19, 2023

Choose a reason for hiding this comment

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

Is this an error condition (e.g. the function must fail if this is not a symlink), or something we can safely ignore? My intuition tells me that if it's e.g. a normal directory, then it's not a symlink and so we have passed the check. But maybe there is something I'm not understanding. I also don't fully understand the differences in behavior if you are using pnpm directly vs. bazel.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The good news is that pnpm guarantees everything here will be a symlink into pkg/ui/node_modules/.pnpm/, so there's no chance of getting a non-symlink here. I can add an isDir() (or use filepath.EvalSymlinks(), which should be safe to use on non-links) if you'd prefer!

Copy link
Collaborator

Choose a reason for hiding this comment

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

No worries, I'm satisfied as long as this is pnpm's default behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep — doc link for the future: https://pnpm.io/symlinked-node-modules-structure

}

// Convert it to a path relative to the Bazel workspace root to make
// it obvious when a link escapes the workspace.
relativeToWorkspace, err := filepath.Rel(
uiDirs.workspace,
filepath.Join(crlModulesPath, resolved),
)
if err != nil {
return fmt.Errorf("could not relativize path %s: %w", resolved, err)
}

// If it doesn't start with '..', it doesn't escape the Bazel
// workspace.
// TODO: Once Go 1.20 is supported here, switch to filepath.IsLocal.
if !strings.HasPrefix(relativeToWorkspace, "..") {
continue
}

// This package escapes the Bazel workspace! Add it to the queue
// with its absolute path for simpler presentation to users.
abs, err := filepath.Abs(relativeToWorkspace)
if err != nil {
return fmt.Errorf("could not absolutize path %s: %w", resolved, err)
}

linkedPackages = append(
linkedPackages,
LinkedPackage{
name: "@cockroachlabs/" + depName,
dir: abs,
},
)
}

// If this internal package has no dependencies provided by pnpm link,
// move on without logging anything.
if len(linkedPackages) == 0 {
continue
}

if !anyPackageEscapesWorkspace {
anyPackageEscapesWorkspace = true
log.Println("Externally-linked package(s) detected:")
}

log.Printf("pkg/ui/workspaces/%s:", filepath.Base(jsPkgRoot))
for _, pkg := range linkedPackages {
log.Printf(" %s <- %s\n", pkg.name, pkg.dir)
}
log.Println()
}

if anyPackageEscapesWorkspace {
msg := strings.TrimSpace(`
At least one JS dependency is linked to another directory on your machine.
Bazel cannot see changes in these packages, which could lead to both
false-positive and false-negative behavior in the UI.
This build has been pre-emptively failed.

To build without the UI, run:
dev build short
To remove all linked dependencies, run:
dev ui clean --all
`) + "\n"

return fmt.Errorf("%s", msg)
}

return nil
}

// makeUIWatchCmd initializes the 'ui watch' subcommand, which sets up a
// live-reloading HTTP server for db-console and a file-watching rebuilder for
// cluster-ui.
Expand Down