Skip to content

Commit b881f11

Browse files
committed
feat: add debug command
1 parent a9f6ce1 commit b881f11

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+6985
-3762
lines changed

package-lock.json

+6,479-3,707
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+13-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"name": "@skyleague/starchart",
3+
"version": "1.2.0",
34
"license": "MIT",
45
"type": "module",
56
"repository": {
@@ -29,16 +30,25 @@
2930
"test": "vitest run"
3031
},
3132
"dependencies": {
32-
"@skyleague/esbuild-lambda": "^5.2.0",
33+
"@aws-sdk/client-iam": "^3.614.0",
34+
"@aws-sdk/client-lambda": "^3.614.0",
35+
"@skyleague/esbuild-lambda": "^5.3.2",
36+
"@skyleague/event-horizon": "^9.0.0",
37+
"aws-iot-device-sdk-v2": "^1.20.0",
3338
"camelcase": "^8.0.0",
3439
"find-root": "^1.1.0",
3540
"globby": "^14.0.2",
3641
"js-yaml": "^4.1.0",
42+
"pino-pretty": "^11.2.1",
43+
"tsx": "^4.16.2",
3744
"yargs": "^17.7.2"
3845
},
3946
"devDependencies": {
40-
"@skyleague/node-standards": "^7.2.0",
41-
"@skyleague/therefore": "^5.9.1",
47+
"@aws-sdk/client-iot": "^3.614.0",
48+
"@aws-sdk/credential-providers": "^3.614.0",
49+
"@skyleague/node-standards": "^7.3.1",
50+
"@skyleague/therefore": "^5.9.2",
51+
"@types/aws-lambda": "^8.10.141",
4252
"@types/find-root": "^1.1.4",
4353
"@types/js-yaml": "^4.0.9",
4454
"@types/yargs": "^17.0.32",

src/commands/build/index.ts

+41-12
Original file line numberDiff line numberDiff line change
@@ -22,39 +22,68 @@ export function builder(yargs: Argv) {
2222
default: ['*'],
2323
string: true,
2424
})
25+
.option('clean', {
26+
type: 'boolean',
27+
default: true,
28+
})
29+
.option('dot', {
30+
type: 'boolean',
31+
default: false,
32+
})
33+
}
34+
35+
export type BuildOptions = {
36+
fnDirs?: string[]
37+
stacks?: string[]
38+
preBuild?: () => Promise<void> | void
39+
postBuild?: () => Promise<void> | void
2540
}
2641

27-
export async function handler(argv: ReturnType<typeof builder>['argv']): Promise<void> {
28-
const { buildDir: _buildDir, artifactDir: _artifactDir, target } = await argv
42+
export async function handler(argv: ReturnType<typeof builder>['argv'], options: BuildOptions = {}): Promise<void> {
43+
const { buildDir: _buildDir, artifactDir: _artifactDir, target, clean, dot } = await argv
44+
const { fnDirs = ['src/**/functions'], preBuild, postBuild } = options
2945
const buildDir = path.join(rootDirectory, _buildDir)
3046
const artifactDir = path.join(rootDirectory, _artifactDir)
3147

3248
const targets = target.flatMap((t) => t.split(','))
3349

34-
if (targets.includes('*')) {
35-
await fs.promises.rm(artifactDir, { recursive: true }).catch(() => void {})
50+
if (clean) {
51+
if (targets.includes('*')) {
52+
fs.rmSync(artifactDir, { recursive: true, force: true })
53+
}
54+
fs.rmSync(buildDir, { recursive: true, force: true })
3655
}
37-
await fs.promises.mkdir(artifactDir).catch(() => void {})
56+
fs.mkdirSync(artifactDir, { recursive: true })
57+
58+
await preBuild?.()
3859

39-
const stacks = await globby(['src/**/functions'], { cwd: rootDirectory, onlyDirectories: true })
40-
const handlers = (await Promise.all(stacks.flatMap(async (fnDir) => listLambdaHandlers(path.join(rootDirectory, fnDir)))))
41-
.flat()
42-
.filter((handler) => targets.includes('*') || targets.some((t) => handler.includes(t)))
60+
const stacks = await globby(fnDirs, { cwd: rootDirectory, onlyDirectories: true, dot })
61+
const handlers = (
62+
await Promise.all(
63+
[
64+
...(options.stacks ?? []),
65+
...stacks.filter((handler) => targets.includes('*') || targets.some((t) => handler.includes(t))),
66+
].flatMap(async (fnDir) => listLambdaHandlers(path.join(rootDirectory, fnDir))),
67+
)
68+
).flat()
4369

44-
const outbase = path.join(rootDirectory, 'src')
70+
const outbase = rootDirectory
4571

4672
await esbuildLambda(rootDirectory, {
4773
esbuild: {
4874
absWorkingDir: rootDirectory,
4975
tsconfig: path.join(rootDirectory, 'tsconfig.dist.json'),
50-
outbase,
76+
outbase: rootDirectory,
5177
},
5278
root: rootDirectory,
79+
modulesRoot: rootDirectory,
5380
entryPoints: handlers.map((fnDir) => path.join(fnDir, 'index.ts')),
5481
outdir: () => buildDir,
55-
forceBundle: () => true,
82+
forceBundle: ({ packageName }) => packageName !== 'aws-crt',
5683
})
5784

85+
await postBuild?.()
86+
5887
await zipHandlers(handlers, {
5988
outbase,
6089
buildDir,

src/commands/deploy/index.ts

+20-8
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import { spawnSync } from 'node:child_process'
22
import path from 'node:path'
33
import { globby } from 'globby'
44
import type { Argv } from 'yargs'
5-
import { builder as buildBuilder, handler as buildHandler } from '../build/index.js'
5+
import { type BuildOptions, builder as buildBuilder, handler as buildHandler } from '../build/index.js'
66

77
export function builder(yargs: Argv) {
88
return buildBuilder(yargs)
9+
.option('stack', {
10+
type: 'array',
11+
default: ['*'],
12+
string: true,
13+
})
914
.option('dir', {
1015
describe: 'environment directory',
1116
type: 'string',
@@ -20,27 +25,34 @@ export function builder(yargs: Argv) {
2025
})
2126
}
2227

23-
export async function handler(argv: ReturnType<typeof builder>['argv']): Promise<void> {
24-
const { target, dir, refresh } = await argv
25-
const targets = target.flatMap((t) => t.split(','))
28+
export interface DeployOptions extends BuildOptions {}
29+
30+
export async function handler(argv: ReturnType<typeof builder>['argv'], options: DeployOptions = {}): Promise<void> {
31+
const { dir, refresh, stack } = await argv
32+
33+
const stacks = stack.flatMap((t) => t.split(','))
2634
const cwd = path.join(process.cwd(), dir)
2735

28-
const [stacks] = await Promise.all([globby(['**/*.hcl'], { cwd: cwd, onlyDirectories: false }), buildHandler(argv)])
36+
const [foundStacks] = await Promise.all([
37+
globby(['**/*.hcl'], { cwd: cwd, onlyDirectories: false }),
38+
buildHandler(argv, options),
39+
])
2940

30-
const groups = stacks
41+
const groups = foundStacks
3142
.map((s) => path.join(cwd, path.dirname(s)))
32-
.filter((stack) => targets.includes('*') || targets.some((t) => stack.includes(t)))
43+
.filter((stack) => stacks.includes('*') || stacks.some((t) => stack.includes(t)))
3344

3445
console.log('Deploying stacks')
3546

3647
spawnSync(
3748
'terragrunt',
3849
[
3950
'--terragrunt-non-interactive',
40-
...(targets.includes('*')
51+
...(stacks.includes('*')
4152
? ['run-all', 'apply']
4253
: ['run-all', 'apply', '--terragrunt-strict-include', ...groups.flatMap((t) => ['--terragrunt-include-dir', t])]),
4354
...(refresh === false ? ['-refresh=false'] : []),
55+
'--terragrunt-fetch-dependency-output-from-state',
4456
],
4557
{
4658
stdio: 'inherit',

src/commands/dev/function.ts

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { IAM } from '@aws-sdk/client-iam'
2+
import type { FunctionConfiguration, Lambda } from '@aws-sdk/client-lambda'
3+
4+
export interface LambdaFunction {
5+
configuration: FunctionConfiguration
6+
tags: { [key: string]: string }
7+
}
8+
export async function patchDebugFunction({
9+
fn,
10+
lambda,
11+
iam,
12+
debugZip,
13+
codeSha256,
14+
}: { fn: LambdaFunction; lambda: Lambda; iam: IAM; debugZip: Uint8Array; codeSha256: string }) {
15+
if (codeSha256 !== fn.configuration.CodeSha256) {
16+
await lambda.updateFunctionCode({
17+
// biome-ignore lint/style/noNonNullAssertion: This is a debug function, so we can assume the configuration is present
18+
FunctionName: fn.configuration.FunctionName!,
19+
ZipFile: debugZip,
20+
})
21+
}
22+
23+
await iam.putRolePolicy({
24+
// biome-ignore lint/style/noNonNullAssertion: <explanation>
25+
RoleName: fn.configuration.Role!.split('/').pop()!,
26+
PolicyName: '.localdebug',
27+
PolicyDocument: JSON.stringify({
28+
Version: '2012-10-17',
29+
Statement: [
30+
{
31+
Effect: 'Allow',
32+
Action: [
33+
'iot:DescribeEndpoint',
34+
'iot:Connect',
35+
'iot:Subscribe',
36+
'iot:Publish',
37+
'iot:RetainPublish',
38+
'iot:Receive',
39+
],
40+
Resource: '*',
41+
},
42+
],
43+
}),
44+
})
45+
}

src/commands/dev/handler.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { tsImport } from 'tsx/esm/api'
2+
3+
process.on('message', async ({ lambdaFn, event, context, rootDirectory }) => {
4+
let handler: ((...args: unknown[]) => unknown) | undefined = undefined
5+
try {
6+
const loaded = await tsImport(`.${lambdaFn.tags.Path}.ts`, `${rootDirectory}/`)
7+
if ('handler' in loaded && typeof loaded.handler === 'function') {
8+
handler = loaded.handler
9+
}
10+
} catch (error) {
11+
console.error(`Error loading handler for ${lambdaFn.tags.Path}`)
12+
process.send?.({ left: (error as { message: string }).message })
13+
return
14+
}
15+
16+
if (handler !== undefined && context !== undefined) {
17+
const response = await handler(event, { ...context, getRemainingTimeInMillis: () => 1000 })
18+
process.send?.({ right: response })
19+
} else {
20+
process.send?.({ left: 'Handler function not found or context is undefined' })
21+
}
22+
})

src/commands/dev/index.ts

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import crypto from 'node:crypto'
2+
import fs from 'node:fs'
3+
import path from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
import { IAM } from '@aws-sdk/client-iam'
6+
import { type FunctionConfiguration, Lambda, paginateListFunctions } from '@aws-sdk/client-lambda'
7+
import {} from '@skyleague/esbuild-lambda'
8+
import type { Argv } from 'yargs'
9+
import { rootDirectory } from '../../lib/constants.js'
10+
import { handler as buildHandler } from '../build/index.js'
11+
import { builder as deployBuilder, handler as deployHandler } from '../deploy/index.js'
12+
import { type LambdaFunction, patchDebugFunction } from './function.js'
13+
import { local } from './local.js'
14+
15+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
16+
17+
function sha256Hash(data: Uint8Array) {
18+
const hash = crypto.createHash('sha256')
19+
hash.update(data)
20+
return hash.digest('base64')
21+
}
22+
23+
export function builder(yargs: Argv) {
24+
return deployBuilder(yargs)
25+
.option('stack', {
26+
type: 'array',
27+
default: ['*'],
28+
string: true,
29+
})
30+
.option('dir', {
31+
describe: 'environment directory',
32+
type: 'string',
33+
default: '.',
34+
example: 'terraform/dev',
35+
demandOption: true,
36+
})
37+
.option('refresh', {
38+
describe: 'refresh the state before applying',
39+
type: 'boolean',
40+
default: true,
41+
})
42+
.option('deploy', {
43+
describe: 'refresh the stacks before starting debug',
44+
type: 'boolean',
45+
default: false,
46+
})
47+
}
48+
49+
export async function handler(argv: ReturnType<typeof builder>['argv']): Promise<void> {
50+
const options = await argv
51+
const { deploy, buildDir: _buildDir, artifactDir: _artifactDir, stack } = options
52+
const artifactDir = path.join(rootDirectory, _artifactDir)
53+
54+
const stacks = stack.flatMap((t) => t.split(','))
55+
56+
const debugPath = '.debug'
57+
const debugDir = path.join(_buildDir, debugPath)
58+
const debugArtifact = path.join(artifactDir, _buildDir, `${debugPath}.zip`)
59+
60+
const debugLambda = path.join(path.join(rootDirectory, debugDir), 'index.ts')
61+
62+
const hasDebugArtifact = fs.existsSync(debugArtifact) && false
63+
64+
const remoteLambda = fs.existsSync(`${__dirname}/lambda.ts`)
65+
? fs.readFileSync(`${__dirname}/lambda.ts`).toString()
66+
: fs.existsSync(`${__dirname}/lambda.js`)
67+
? fs.readFileSync(`${__dirname}/lambda.js`).toString()
68+
: undefined
69+
const remoteLambdaContent = [remoteLambda, ' export const handler = proxyHandler()'].join('\n')
70+
71+
if (remoteLambda === undefined) {
72+
throw new Error('No remote lambda contents found, please provide a lambda.ts or lambda.js file')
73+
}
74+
75+
const buildRemoteLambdaArtifact =
76+
!hasDebugArtifact || (fs.existsSync(debugLambda) && fs.readFileSync(debugLambda).toString() !== remoteLambdaContent)
77+
78+
const preBuild = () => {
79+
fs.mkdirSync(path.join(rootDirectory, debugDir), { recursive: true })
80+
81+
fs.writeFileSync(debugLambda, remoteLambdaContent)
82+
}
83+
84+
if (deploy) {
85+
await deployHandler(
86+
{ ...options, clean: false },
87+
{
88+
stacks: buildRemoteLambdaArtifact ? [debugDir] : [],
89+
preBuild,
90+
},
91+
)
92+
} else {
93+
await buildHandler(
94+
{ ...options, fnDir: [], clean: false },
95+
{
96+
fnDirs: [],
97+
stacks: !hasDebugArtifact ? [debugDir] : [],
98+
preBuild,
99+
},
100+
)
101+
}
102+
const lambda = new Lambda({
103+
region: 'eu-west-1',
104+
})
105+
106+
const iam = new IAM({
107+
region: 'eu-west-1',
108+
})
109+
110+
const paginator = paginateListFunctions({ client: lambda, pageSize: 10 }, {})
111+
112+
const configurations: FunctionConfiguration[] = []
113+
114+
for await (const page of paginator) {
115+
// only keep funtions that start with the any of the given stacks
116+
configurations.push(...(page.Functions?.filter((f) => stacks.some((s) => f.FunctionName?.startsWith(s))) ?? []))
117+
}
118+
119+
const functions: LambdaFunction[] = (
120+
await Promise.all(
121+
configurations.map(async (f) => ({
122+
configuration: f,
123+
tags: (await lambda.listTags({ Resource: f.FunctionArn })).Tags ?? {},
124+
})),
125+
)
126+
).filter((f) => f.tags.Stack !== undefined && stack.includes(f.tags.Stack))
127+
128+
const zipFile = new Uint8Array(fs.readFileSync(debugArtifact).buffer)
129+
const codeSha256 = sha256Hash(zipFile)
130+
await Promise.all(functions.map((f) => patchDebugFunction({ fn: f, lambda, debugZip: zipFile, iam, codeSha256 })))
131+
132+
await local(functions)
133+
}
134+
135+
export default {
136+
command: 'dev',
137+
describe: 'starts a dev deploy',
138+
builder,
139+
handler,
140+
}

0 commit comments

Comments
 (0)