1010package main
1111
1212import (
13+ "errors"
1314 "fmt"
1415 "log"
1516 "net/url"
@@ -48,6 +49,8 @@ func makeUICmd(d *dev) *cobra.Command {
4849
4950// UIDirectories contains the absolute path to the root of each UI sub-project.
5051type UIDirectories struct {
52+ workspace string
53+ // workspace is the absolute path to ./ .
5154 // root is the absolute path to ./pkg/ui.
5255 root string
5356 // clusterUI is the absolute path to ./pkg/ui/workspaces/cluster-ui.
@@ -58,6 +61,10 @@ type UIDirectories struct {
5861 e2eTests string
5962 // eslintPlugin is the absolute path to ./pkg/ui/workspaces/eslint-plugin-crdb.
6063 eslintPlugin string
64+ // protoOss is the absolute path to ./pkg/ui/workspaces/db-console/src/js/.
65+ protoOss string
66+ // protoCcl is the absolute path to ./pkg/ui/workspaces/db-console/ccl/src/js/.
67+ protoCcl string
6168}
6269
6370// getUIDirs computes the absolute path to the root of each UI sub-project.
@@ -68,14 +75,170 @@ func getUIDirs(d *dev) (*UIDirectories, error) {
6875 }
6976
7077 return & UIDirectories {
78+ workspace : workspace ,
7179 root : filepath .Join (workspace , "./pkg/ui" ),
7280 clusterUI : filepath .Join (workspace , "./pkg/ui/workspaces/cluster-ui" ),
7381 dbConsole : filepath .Join (workspace , "./pkg/ui/workspaces/db-console" ),
7482 e2eTests : filepath .Join (workspace , "./pkg/ui/workspaces/e2e-tests" ),
7583 eslintPlugin : filepath .Join (workspace , "./pkg/ui/workspaces/eslint-plugin-crdb" ),
84+ protoOss : filepath .Join (workspace , "./pkg/ui/workspaces/db-console/src/js" ),
85+ protoCcl : filepath .Join (workspace , "./pkg/ui/workspaces/db-console/ccl/src/js" ),
7686 }, nil
7787}
7888
89+ // assertNoLinkedNpmDeps looks for JS packages linked outside the Bazel
90+ // workspace (typically via `pnpm link`). It returns an error if:
91+ //
92+ // 'targets' contains a Bazel target that requires the web UI
93+ // AND
94+ // a node_modules/ tree exists within pkg/ui (or its subtrees)
95+ // AND
96+ // a @cockroachlabs-scoped package is symlinked to an external directory
97+ //
98+ // (or if any error occurs while performing one of those checks).
99+ func (d * dev ) assertNoLinkedNpmDeps (targets []buildTarget ) error {
100+ uiWillBeBuilt := false
101+ for _ , target := range targets {
102+ // TODO: This could potentially be a bazel query, e.g.
103+ // 'somepath(${target.fullName}, //pkg/ui/workspaces/db-console:*)' or
104+ // similar, but with only two eligible targets it doesn't seem quite
105+ // worth it.
106+ if target .fullName == cockroachTarget || target .fullName == cockroachTargetOss {
107+ uiWillBeBuilt = true
108+ break
109+ }
110+ }
111+ if ! uiWillBeBuilt {
112+ // If no UI build is required, the presence of an externally-linked
113+ // package doesn't matter.
114+ return nil
115+ }
116+
117+ // Find the current workspace and build some relevant absolute paths.
118+ uiDirs , err := getUIDirs (d )
119+ if err != nil {
120+ return fmt .Errorf ("could not check for linked NPM dependencies: %w" , err )
121+ }
122+
123+ jsPkgRoots := []string {
124+ uiDirs .root ,
125+ uiDirs .eslintPlugin ,
126+ uiDirs .protoOss ,
127+ uiDirs .protoCcl ,
128+ uiDirs .clusterUI ,
129+ uiDirs .dbConsole ,
130+ uiDirs .e2eTests ,
131+ }
132+
133+ type LinkedPackage struct {
134+ name string
135+ dir string
136+ }
137+
138+ anyPackageEscapesWorkspace := false
139+
140+ // Check for symlinks in each package's node_modules/@cockroachlabs/ dir.
141+ for _ , jsPkgRoot := range jsPkgRoots {
142+ crlModulesPath := filepath .Join (jsPkgRoot , "node_modules/@cockroachlabs" )
143+ crlDeps , err := d .os .ReadDir (crlModulesPath )
144+
145+ // If node_modules/@cockroachlabs doesn't exist, it's likely that JS
146+ // dependencies haven't been installed outside the Bazel workspace.
147+ // This is expected for non-UI devs, and is a safe state.
148+ if errors .Is (err , os .ErrNotExist ) {
149+ continue
150+ }
151+ if err != nil {
152+ return fmt .Errorf ("could not @cockroachlabs/ packages: %w" , err )
153+ }
154+
155+ linkedPackages := []LinkedPackage {}
156+
157+ // For each dependency in node_modules/@cockroachlabs/ ...
158+ for _ , depName := range crlDeps {
159+ // Ignore empty strings, which are produced by d.os.ReadDir in
160+ // dry-run mode.
161+ if depName == "" {
162+ continue
163+ }
164+
165+ // Resolve the possible symlink.
166+ depPath := filepath .Join (crlModulesPath , depName )
167+ resolved , err := d .os .Readlink (depPath )
168+ if err != nil {
169+ return fmt .Errorf ("could not evaluate symlink %s: %w" , depPath , err )
170+ }
171+
172+ // Convert it to a path relative to the Bazel workspace root to make
173+ // it obvious when a link escapes the workspace.
174+ relativeToWorkspace , err := filepath .Rel (
175+ uiDirs .workspace ,
176+ filepath .Join (crlModulesPath , resolved ),
177+ )
178+ if err != nil {
179+ return fmt .Errorf ("could not relativize path %s: %w" , resolved , err )
180+ }
181+
182+ // If it doesn't start with '..', it doesn't escape the Bazel
183+ // workspace.
184+ // TODO: Once Go 1.20 is supported here, switch to filepath.IsLocal.
185+ if ! strings .HasPrefix (relativeToWorkspace , ".." ) {
186+ continue
187+ }
188+
189+ // This package escapes the Bazel workspace! Add it to the queue
190+ // with its absolute path for simpler presentation to users.
191+ abs , err := filepath .Abs (relativeToWorkspace )
192+ if err != nil {
193+ return fmt .Errorf ("could not absolutize path %s: %w" , resolved , err )
194+ }
195+
196+ linkedPackages = append (
197+ linkedPackages ,
198+ LinkedPackage {
199+ name : "@cockroachlabs/" + depName ,
200+ dir : abs ,
201+ },
202+ )
203+ }
204+
205+ // If this internal package has no dependencies provided by pnpm link,
206+ // move on without logging anything.
207+ if len (linkedPackages ) == 0 {
208+ continue
209+ }
210+
211+ if ! anyPackageEscapesWorkspace {
212+ anyPackageEscapesWorkspace = true
213+ log .Println ("Externally-linked package(s) detected:" )
214+ }
215+
216+ log .Printf ("pkg/ui/workspaces/%s:" , filepath .Base (jsPkgRoot ))
217+ for _ , pkg := range linkedPackages {
218+ log .Printf (" %s <- %s\n " , pkg .name , pkg .dir )
219+ }
220+ log .Println ()
221+ }
222+
223+ if anyPackageEscapesWorkspace {
224+ msg := strings .TrimSpace (`
225+ At least one JS dependency is linked to another directory on your machine.
226+ Bazel cannot see changes in these packages, which could lead to both
227+ false-positive and false-negative behavior in the UI.
228+ This build has been pre-emptively failed.
229+
230+ To build without the UI, run:
231+ dev build short
232+ To remove all linked dependencies, run:
233+ dev ui clean --all
234+ ` ) + "\n "
235+
236+ return fmt .Errorf ("%s" , msg )
237+ }
238+
239+ return nil
240+ }
241+
79242// makeUIWatchCmd initializes the 'ui watch' subcommand, which sets up a
80243// live-reloading HTTP server for db-console and a file-watching rebuilder for
81244// cluster-ui.
0 commit comments