Skip to content

Commit 4195474

Browse files
authored
Add node wrapper and nodejs debug helper image (#34)
1 parent ba2c7c1 commit 4195474

10 files changed

Lines changed: 928 additions & 1 deletion

File tree

go/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
1212
&& patch -p0 < delve-only-same-user.patch
1313

1414
# Produce an as-static-as-possible dlv binary to work on musl and glibc
15-
RUN cd delve-1.4.0 && CGO_ENABLED=0 go build -o /go/dlv -ldflags '-extldflags "-static"' ./cmd/dlv/
15+
RUN cd delve-1.4.0 && CGO_ENABLED=0 go build -o /go/dlv -ldflags '-s -w -extldflags "-static"' ./cmd/dlv/
1616

1717
# Now populate the duct-tape image with the language runtime debugging support files
1818
# The debian image is about 95MB bigger

nodejs/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM golang:1.14.1 as build
2+
COPY . .
3+
# Produce an as-static-as-possible dlv binary to work on musl and glibc
4+
RUN GOPATH="" CGO_ENABLED=0 go build -o node -ldflags '-s -w -extldflags "-static"' wrapper.go
5+
6+
# Now populate the duct-tape image with the language runtime debugging support files
7+
# The debian image is about 95MB bigger
8+
FROM busybox
9+
# The install script copies all files in /duct-tape to /dbg
10+
COPY install.sh /
11+
CMD ["/bin/sh", "/install.sh"]
12+
WORKDIR /duct-tape
13+
COPY --from=build /go/node nodejs/bin/

nodejs/go.mod

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module github.com/GoogleContainerTools/container-debug-support/nodejs
2+
3+
go 1.14
4+
5+
require (
6+
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
7+
github.com/sirupsen/logrus v1.4.2
8+
)

nodejs/go.sum

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2+
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
3+
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
4+
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
5+
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
6+
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
7+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
8+
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
9+
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
10+
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
11+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
12+
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
13+
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
14+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

nodejs/install.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/bin/sh
2+
set -e
3+
4+
if [ ! -d /dbg ]; then
5+
echo "Error: installation requires a volume mount at /dbg" 1>&2
6+
exit 1
7+
fi
8+
9+
echo "Installing runtime debugging support files in /dbg"
10+
tar cf - -C /duct-tape . | tar xf - -C /dbg
11+
echo "Installation complete"

nodejs/wrapper.go

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/*
2+
Copyright 2020 The Skaffold Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// A wrapper for node executables to support debugging of application scripts.
18+
// Many NodeJS applications use NodeJS-based launch tools (e.g., npm,
19+
// nodemon), and often use several in combination. This makes it very
20+
// difficult to start debugging the application as `--inspect`s are usually
21+
// intercepted by one of the launch tools. When executing a `node_modules`
22+
// script, this wrapper strips out and propagates `--inspect`-like arguments
23+
// via `NODE_DEBUG`. When executing an app script, this wrapper then inlines
24+
// the `NODE_DEBUG` when found.
25+
package main
26+
27+
import (
28+
"context"
29+
"fmt"
30+
"io"
31+
"os"
32+
"os/exec"
33+
"path/filepath"
34+
"strings"
35+
36+
shell "github.com/kballard/go-shellquote"
37+
"github.com/sirupsen/logrus"
38+
)
39+
40+
// nodeContext allows manipulating the launch context for node.
41+
type nodeContext struct {
42+
program string
43+
args []string
44+
env map[string]string
45+
}
46+
47+
func main() {
48+
logrus.SetLevel(logrusLevel())
49+
logrus.Debugln("Launched: ", os.Args)
50+
51+
env := envToMap(os.Environ())
52+
// suppress npm warnings when node on PATH isn't the node used for npm
53+
env["npm_config_scripts_prepend_node_path"] = "false"
54+
nc := nodeContext{program: os.Args[0], args: os.Args[1:], env: env}
55+
if err := run(&nc, os.Stdin, os.Stdout, os.Stderr); err != nil {
56+
logrus.Fatal(err)
57+
}
58+
}
59+
60+
func logrusLevel() logrus.Level {
61+
v := os.Getenv("WRAPPER_VERBOSE")
62+
if v != "" {
63+
if l, err := logrus.ParseLevel(v); err == nil {
64+
return l
65+
}
66+
logrus.Warnln("Unknown logging level: WRAPPER_VERBOSE=", v)
67+
}
68+
return logrus.WarnLevel
69+
}
70+
71+
func run(nc *nodeContext, stdin io.Reader, stdout, stderr io.Writer) error {
72+
if err := nc.unwrap(); err != nil {
73+
return fmt.Errorf("could not unwrap: %w", err)
74+
}
75+
logrus.Debugln("unwrapped: ", nc.program)
76+
77+
// Use an absolute path in case we're being run within a node_modules directory
78+
// If there's an error, then hand off immediately to the real node.
79+
script := findScript(nc.args)
80+
if abs, err := filepath.Abs(script); err == nil {
81+
script = abs
82+
} else {
83+
logrus.Warn("could not access script: ", err)
84+
return nc.exec(stdin, stdout, stderr)
85+
}
86+
logrus.Debugln("script: ", script)
87+
88+
nodeDebugOption, hasNodeDebug := nc.env["NODE_DEBUG"]
89+
if hasNodeDebug {
90+
logrus.Debugln("found NODE_DEBUG=", nodeDebugOption)
91+
}
92+
93+
// if we're about to execute the application script, install the NODE_DEBUG
94+
// arguments if found and go
95+
if isApplicationScript(script) || script == "" {
96+
if hasNodeDebug {
97+
nc.stripInspectArgs() // top-level debug options win
98+
nc.addNodeArg(nodeDebugOption)
99+
delete(nc.env, "NODE_DEBUG")
100+
}
101+
return nc.exec(stdin, stdout, stderr)
102+
}
103+
104+
// We're executing a node module: strip any --inspect args and propagate
105+
inspectArg := nc.stripInspectArgs()
106+
if inspectArg != "" {
107+
logrus.Debugf("Stripped %q as not an app script", inspectArg)
108+
if !hasNodeDebug {
109+
logrus.Debugln("Setting NODE_DEBUG=", inspectArg)
110+
nc.env["NODE_DEBUG"] = inspectArg
111+
}
112+
}
113+
114+
// nodemon needs special handling as `nodemon --inspect` will use spawn to invoke a
115+
// child node, which picks up this wrapped node. Otherwise nodemon uses fork to launch
116+
// the actual application script file directly, which circumvents the use of this node wrapper.
117+
nc.handleNodemon()
118+
119+
return nc.exec(stdin, stdout, stderr)
120+
}
121+
122+
// unwrap looks for the real node executable (not this wrapper).
123+
func (nc *nodeContext) unwrap() error {
124+
if nc == nil {
125+
return fmt.Errorf("nil context")
126+
}
127+
128+
// Here we try to find the original program. When a program is
129+
// resolved from the PATH, most shells will set argv[0] to the
130+
// command and so it won't appear to exist and so the first file
131+
// resolved in the PATH should be this program.
132+
origInfo, err := os.Stat(nc.program)
133+
origFound := err == nil
134+
if err != nil && !os.IsNotExist(err) {
135+
return fmt.Errorf("unable to stat %q: %v", nc.program, err)
136+
}
137+
138+
path := nc.env["PATH"]
139+
base := filepath.Base(nc.program)
140+
for _, dir := range strings.Split(path, string(os.PathListSeparator)) {
141+
p := filepath.Join(dir, base)
142+
if pInfo, err := os.Stat(p); err == nil {
143+
if !origFound {
144+
// the original nc.program was not resolved, meaning this
145+
// it had been resolved in the PATH, so treat this first
146+
// instance as the original file and continue searching
147+
logrus.Debugln("unwrap: presumed wrapper at ", p)
148+
origInfo = pInfo
149+
origFound = true
150+
} else if !os.SameFile(origInfo, pInfo) {
151+
logrus.Debugf("unwrap: replacing %s -> %s", nc.program, p)
152+
nc.program = p
153+
return nil
154+
}
155+
}
156+
}
157+
return fmt.Errorf("could not find %q in PATH", base)
158+
}
159+
160+
// stripInspectArgs removes all `--inspect*` args from both the command-line and from
161+
// NODE_OPTIONS. It returns the last inspect arg or "" if there were no inspect arguments.
162+
func (nc *nodeContext) stripInspectArgs() string {
163+
foundOption := ""
164+
if options, found := nc.env["NODE_OPTIONS"]; found {
165+
if args, err := shell.Split(options); err != nil {
166+
logrus.Warnf("NODE_OPTIONS cannot be split: %v", err)
167+
} else {
168+
args, inspectArg := stripInspectArg(args)
169+
if inspectArg != "" {
170+
logrus.Debugf("Found %q in NODE_OPTIONS", inspectArg)
171+
nc.env["NODE_OPTIONS"] = shell.Join(args...)
172+
foundOption = inspectArg
173+
}
174+
}
175+
}
176+
strippedArgs, inspectArg := stripInspectArg(nc.args)
177+
if inspectArg != "" {
178+
logrus.Debugf("Found %q in command-line", inspectArg)
179+
nc.args = strippedArgs
180+
foundOption = inspectArg
181+
}
182+
return foundOption
183+
}
184+
185+
func (nc *nodeContext) handleNodemon() {
186+
if nodeDebug, found := nc.env["NODE_DEBUG"]; found {
187+
// look for the nodemon script (if it appears) and insert the --inspect argument
188+
for i, arg := range nc.args {
189+
if len(arg) > 0 && arg[0] != '-' && strings.Contains(arg, "/nodemon") {
190+
nc.args = append(nc.args, "")
191+
copy(nc.args[i+2:], nc.args[i+1:])
192+
nc.args[i+1] = nodeDebug
193+
delete(nc.env, "NODE_DEBUG")
194+
logrus.Debugf("special handling for nodemon: %q", nc.args)
195+
return
196+
}
197+
}
198+
}
199+
}
200+
201+
func (nc *nodeContext) addNodeArg(nodeArg string) {
202+
// find the script location and insert the provided argument
203+
for i, arg := range nc.args {
204+
if len(arg) > 0 && arg[0] != '-' {
205+
nc.args = append(nc.args, "")
206+
copy(nc.args[i+1:], nc.args[i:])
207+
nc.args[i] = nodeArg
208+
logrus.Debugf("added node arg: %q", nc.args)
209+
return
210+
}
211+
}
212+
// script not found so add at end
213+
nc.args = append(nc.args, nodeArg)
214+
}
215+
216+
// exec runs the command, and returns an error should one occur.
217+
func (nc *nodeContext) exec(in io.Reader, out, err io.Writer) error {
218+
logrus.Debugf("exec: %s %v (env: %v)", nc.program, nc.args, nc.env)
219+
cmd := exec.CommandContext(context.Background(), nc.program, nc.args...)
220+
cmd.Env = envFromMap(nc.env)
221+
cmd.Stdin = in
222+
cmd.Stdout = out
223+
cmd.Stderr = err
224+
return cmd.Run()
225+
}
226+
227+
// findScript returns the path to the node script that will be executed.
228+
// Returns an empty string if no script was found.
229+
func findScript(args []string) string {
230+
// a bit of a hack, but all node options are of the form `--arg=option`
231+
for _, arg := range args {
232+
if len(arg) > 0 && arg[0] != '-' {
233+
return arg
234+
}
235+
}
236+
return ""
237+
}
238+
239+
// isApplicationScript return true if the script appears to be an application
240+
// script, or false if a library (node_modules) script or `npm` (special case).
241+
func isApplicationScript(path string) bool {
242+
// We could consider checking if the parent's base name is `bin`?
243+
return !strings.HasPrefix(path, "node_modules/") && !strings.Contains(path, "/node_modules/") &&
244+
!strings.HasSuffix(path, "/bin/npm")
245+
}
246+
247+
// envToMap turns a set of VAR=VALUE strings to a map.
248+
func envToMap(entries []string) map[string]string {
249+
m := make(map[string]string)
250+
for _, entry := range entries {
251+
kv := strings.SplitN(entry, "=", 2)
252+
m[kv[0]] = kv[1]
253+
}
254+
return m
255+
}
256+
257+
// envToMap turns a map of variable:value pairs into a set of VAR=VALUE strings.
258+
func envFromMap(env map[string]string) []string {
259+
var m []string
260+
for k, v := range env {
261+
m = append(m, k+"="+v)
262+
}
263+
return m
264+
}
265+
266+
// stripInspectArg searches and removes all node `--inspect` style arguments, returning the
267+
// altered arguments and the inspect argument.
268+
func stripInspectArg(args []string) ([]string, string) {
269+
// inspect directives are always a single argument: `node --inspect 9226` causes node to load 9226 as a file
270+
var newArgs []string
271+
inspectArg := "" // default case: no inspect arg found
272+
273+
for i, arg := range args {
274+
if strings.HasPrefix(arg, "--inspect") {
275+
// todo: we should coalesce --inspect-port=xxx
276+
inspectArg = arg
277+
continue
278+
}
279+
280+
// if at end of node options, copy remaining arguments
281+
// "--" marks end of node options
282+
if arg == "--" || len(arg) == 0 || arg[0] != '-' {
283+
newArgs = append(newArgs, args[i:]...)
284+
break
285+
}
286+
newArgs = append(newArgs, arg)
287+
}
288+
return newArgs, inspectArg
289+
}

0 commit comments

Comments
 (0)