|
| 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