File size: 12,168 Bytes
6582e08
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
/*!
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

import * as os from 'os'
import * as path from 'path'
import * as vscode from 'vscode'
import { GoDebugConfiguration, goDebuggerPath, isImageLambdaConfig } from '../../../lambda/local/debugConfiguration'
import { RuntimeFamily } from '../../../lambda/models/samLambdaRuntime'
import * as pathutil from '../../../shared/utilities/pathUtils'
import { ExtContext } from '../../extensions'
import { findParentProjectFile } from '../../utilities/workspaceUtils'
import { DefaultSamLocalInvokeCommand, waitForDebuggerMessages } from '../cli/samCliLocalInvoke'
import { runLambdaFunction } from '../localLambdaRunner'
import { SamLaunchRequestArgs } from './awsSamDebugger'
import { getLogger } from '../../logger/logger'
import fs from '../../fs/fs'
import { ChildProcess } from '../../utilities/processUtils'
import { Timeout } from '../../utilities/timeoutUtils'
import { SpawnOptions } from 'child_process' // eslint-disable-line no-restricted-imports
import * as nls from 'vscode-nls'
import { sleep } from '../../utilities/timeoutUtils'
import globals from '../../extensionGlobals'
import { ToolkitError } from '../../errors'
const localize = nls.loadMessageBundle()

/**
 * Launches and attaches debugger to a SAM Go project. A general overview of the program flow:
 * 1. We cross-compile a Delve binary for the container using Delve's source, either from the user's
 *    GOPATH or using 'go get'. The binary is placed in the extension's global storage path with
 *    path 'debuggers/delve/dlv'
 * 3. SAM CLI will then mount the 'delve' directory to '/tmp/lambci_debug'. We use --debugger-path
 *    to specify which directory to mount. SAM CLI will always look for 'dlv', so the binary must
 *    be compiled with that name.
 * 4. The generate configuration options are passed along to SAM build + SAM invoke which will
 *    compile the handler using the path specified by 'codeRoot'. The handler entry point must be
 *    a direct child of 'codeRoot', otherwise it will fail to build. Builds are done by a sub-module
 *    of SAM CLI [1]. SAM invoke then mounts the build-artifact directory as '/var/task/[HandlerName]'
 * 5. Something called the 'Runtime Interface Emulator' is started in the container, which then
 *    starts an intermediate program [2] that starts the Delve debugger in 'headless exec' mode.
 *    The handler is finally executed by Delve. We can now communicate with Delve using the Go
 *    extension's debug adapter client.
 * 6. All paths communicated by Delve will be absolute from the moment it was compiled. So any
 *    kind of path mappings are unncessary as long as the binary is compiled directly from source.
 *    In other words, don't build the binary from a copy of the source.
 *
 * References:
 *  [1] https://github.com/aws/aws-lambda-builders/blob/b663326079c871e50f1545f36f9695f6958cfaa2/aws_lambda_builders/workflows/go_modules/builder.py
 *  [2] https://github.com/lambci/docker-lambda/blob/f6b4765a9b659ceb949c34b19390026820ddd462/go1.x/run/aws-lambda-mock.go
 */
export async function invokeGoLambda(ctx: ExtContext, config: GoDebugConfiguration): Promise<GoDebugConfiguration> {
    config.samLocalInvokeCommand = new DefaultSamLocalInvokeCommand([waitForDebuggerMessages.GO_DELVE])
    // eslint-disable-next-line @typescript-eslint/unbound-method
    config.onWillAttachDebugger = waitForDelve

    if (!config.noDebug && !(await installDebugger(config.debuggerPath!))) {
        throw new ToolkitError(
            localize('AWS.sam.debugger.godelve.failed', 'Failed to install Delve for the lambda container.'),
            {
                code: 'NoDelveInstallation',
            }
        )
    }

    const c = (await runLambdaFunction(ctx, config, async () => {})) as GoDebugConfiguration
    return c
}

/**
 * Triggered before the debugger attachment process begins. We should verify that the debugger is ready to go
 * before returning. The Delve DAP will only accept a single client, so checking if the ports are in use will
 * cause Delve to terminate.
 *
 * @param debugPort Port to check for activity
 * @param timeout Cancellation token to prevent stalling
 */
async function waitForDelve(debugPort: number, timeout: Timeout) {
    await sleep(1000)
}

export async function getSamProjectDirPathForFile(filepath: string): Promise<string> {
    const modulePath = await findParentProjectFile(vscode.Uri.parse(filepath), /^go\.mod$/)
    if (!modulePath) {
        throw new Error(`Cannot find go.mod for: ${filepath}`)
    }

    return path.dirname(modulePath.fsPath)
}

/**
 * Gathers and sets launch-config info by inspecting the workspace and creating
 * temp files/directories as needed.
 *
 * Does NOT execute/invoke SAM, docker, etc.
 */
export async function makeGoConfig(config: SamLaunchRequestArgs): Promise<GoDebugConfiguration> {
    if (!config.baseBuildDir) {
        throw Error('invalid state: config.baseBuildDir was not set')
    }

    config.codeRoot =
        config.codeRoot ??
        pathutil.normalize(await getSamProjectDirPathForFile(config?.templatePath ?? config.documentUri!.fsPath))

    if (!config.codeRoot) {
        throw Error('missing launch.json, template.yaml, and failed to discover project root')
    }

    const port: number = config.debugPort ?? -1

    config.codeRoot = pathutil.normalize(config.codeRoot)

    // We want to persist the binary we build since it takes a non-trivial amount of time to build
    config.debuggerPath = path.join(globals.context.globalStorageUri.fsPath, 'debuggers', 'delve')

    const isImageLambda = await isImageLambdaConfig(config)

    // Reference: https://github.com/aws/aws-sam-cli/blob/4543732bf3c0da3b57fe1e5aa43ce3f41d2bd0ba/samcli/local/docker/lambda_debug_settings.py#L94-L103
    // These are the default settings. For some reason SAM CLI is not setting them even if the container
    // environment file does not exist. SAM CLI will skip the debugging step if these are not set!
    if (isImageLambda && !config.noDebug) {
        config.containerEnvVars = {
            _AWS_LAMBDA_GO_DEBUGGING: '1',
            _AWS_LAMBDA_GO_DELVE_API_VERSION: '2',
            _AWS_LAMBDA_GO_DELVE_PATH: path.posix.join(goDebuggerPath, 'dlv'),
            _AWS_LAMBDA_GO_DELVE_LISTEN_PORT: port.toString(),
        }
    }

    // Make a go launch-config from the generic config.
    const goLaunchConfig: GoDebugConfiguration = {
        ...config, // Compose.
        type: 'go',
        processName: 'godelve',
        request: 'attach',
        mode: config.noDebug ? undefined : 'remote',
        runtimeFamily: RuntimeFamily.Go,
        preLaunchTask: undefined,
        host: 'localhost',
        port: port,
        skipFiles: [],
        debugArgs: isImageLambda || config.noDebug ? undefined : ['-delveAPI=2'],
        debugAdapter: 'legacy', // Just in case the Go extension decides to make Delve DAP the default
    }

    return goLaunchConfig
}

interface InstallScript {
    path: string
    options?: SpawnOptions
}

/**
 * We want to cross-compile Delve using the version currently installed by the user. Git is used to check for the current
 * version by locating the package directory in the user's GOPATH. We append this version to an install script, so we know
 * in the future if we already built it.
 *
 * @param debuggerPath Installation path for the debugger
 * @param isWindows Flag for making a windows script
 * @returns Path for the debugger install script, undefined if we already built the binary
 */
async function makeInstallScript(debuggerPath: string, isWindows: boolean): Promise<InstallScript | undefined> {
    let script: string = isWindows ? '' : '#!/bin/sh\n'
    const delveRepo: string = 'github.com/go-delve/delve'
    const scriptExt: string = isWindows ? 'cmd' : 'sh'
    const delvePath: string = path.posix.join(debuggerPath, 'dlv')
    const installOptions: SpawnOptions = { env: { ...process.env } }
    let delveVersion: string = ''

    // Since Go1.16, GO111MODULE is on by default. This causes Go to require the
    // existence of a go.mod file or else it gives up. GO111MODULE=off prevents
    // Go from trying to find the manifest file and uses GOPATH provided below.
    installOptions.env!['GO111MODULE'] = 'off'

    async function getDelveVersion(repo: string, silent: boolean): Promise<string> {
        try {
            return (
                await ChildProcess.run('git', ['-C', repo, 'describe', '--tags', '--abbrev=0'], { collect: true })
            ).stdout.trim()
        } catch (e) {
            if (!silent) {
                throw e
            }
            return ''
        }
    }

    // It's fine if we can't get the latest Delve version, the Toolkit will use the last built one instead
    try {
        const result = await ChildProcess.run('go', ['env', '-json'], { collect: true })
        const goPath: string = JSON.parse(result.stdout).GOPATH
        let repoPath: string = path.join(goPath, 'src', delveRepo)

        if (!getDelveVersion(repoPath, true)) {
            getLogger().info(
                localize(
                    'AWS.sam.debugger.godelve.download',
                    'The Delve repo was not found in your GOPATH. Downloading in a temporary directory...'
                )
            )
            installOptions.env!['GOPATH'] = debuggerPath
            repoPath = path.join(debuggerPath, 'src', delveRepo)
            const args = ['get', '-d', `${delveRepo}/cmd/dlv`]
            const out = await ChildProcess.run('go', args, { ...(installOptions as any), collect: true })
            getLogger().debug('"go %O": %s', args, out)
        }

        delveVersion = await getDelveVersion(repoPath, false)
    } catch (e) {
        getLogger().debug('Failed to get latest Delve version: %O', e as Error)
    }

    delveVersion = delveVersion.replace('v', '-')
    const installScriptPath: string = path.join(debuggerPath, `install${delveVersion}.${scriptExt}`)
    const alreadyInstalled = await fs.exists(installScriptPath)

    if (alreadyInstalled && delveVersion !== '') {
        return undefined
    }

    installOptions.env!['GOARCH'] = 'amd64'
    installOptions.env!['GOOS'] = 'linux'

    script += `go build -o "${delvePath}" "${delveRepo}/cmd/dlv"\n`

    await fs.writeFile(installScriptPath, script, 'utf8')
    await fs.chmod(installScriptPath, 0o755)

    return { path: installScriptPath, options: installOptions }
}

/**
 * Downloads and builds the delve debugger for our container
 *
 * @param debuggerPath Installation path for the debugger
 * @returns False when installation fails
 */
async function installDebugger(debuggerPath: string): Promise<boolean> {
    await fs.mkdir(debuggerPath)
    const isWindows: boolean = os.platform() === 'win32'
    let installScript: InstallScript | undefined

    try {
        installScript = await makeInstallScript(debuggerPath, isWindows)

        if (!installScript) {
            return true
        }

        const childProcess = new ChildProcess(installScript.path, [], { spawnOptions: installScript.options })
        const install = await childProcess.run({
            onStdout: (text: string) => getLogger().info(`[Delve install script] -> ${text}`),
            onStderr: (text: string) => getLogger().error(`[Delve install script] -> ${text}`),
        })

        const code = install.exitCode
        if (!(await fs.exists(path.join(debuggerPath, 'dlv')))) {
            throw new Error(`Install script did not generate the Delve binary: exit code ${code}`)
        } else if (code) {
            getLogger().warn(`Install script did not sucessfully run, using old Delve binary...`)
        } else {
            getLogger().info(`Installed Delve debugger in ${debuggerPath}`)
        }
    } catch (e) {
        if (installScript && (await fs.exists(installScript.path))) {
            await fs.delete(installScript.path) // Removes the install script since it failed
        }
        getLogger().error('Failed to cross-compile Delve debugger: %O', e as Error)
        return false
    }

    return true
}