diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index dfdd3803..f14155a9 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -10,6 +10,11 @@ # - Allows publishing a specific package to npm # - Supports prerelease and dry-run modes # +# RELEASE CANARY (after a publish, or on demand): +# - Installs the exact published versions into a CommonJS Serverless/Webpack app +# - Deploys, invokes, and removes an ephemeral Node 22 Lambda via GitHub OIDC +# - Requires the AWS_LAMBDA_CANARY_ROLE_ARN repository variable; skipped while it is unset +# name: release-please on: @@ -41,6 +46,11 @@ on: description: 'Is this a dry run. If so no package will be published.' type: boolean required: true + canary_versions: + description: 'Run only the Lambda release canary against these exact published versions, e.g. ai-server=0.4.0,ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0' + type: string + required: false + default: '' jobs: release-please: @@ -303,7 +313,7 @@ jobs: manual-publish: runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' + if: ${{ github.event_name == 'workflow_dispatch' && inputs.canary_versions == '' }} permissions: id-token: write contents: read @@ -319,3 +329,123 @@ jobs: workspace_path: ${{ inputs.workspace_path }} prerelease: ${{ inputs.prerelease }} dry_run: ${{ inputs.dry_run }} + + # --- Published-artifact Lambda release canary --- + # + # This job installs the versions that npm now serves into a clean CommonJS package. + # Serverless 3, Webpack 5, and webpack-node-externals 3 bundle that package. + # A real Node 22 Lambda then loads it, and the job deletes the Lambda again. + # The job loads packages only. It uses no SDK key, no flag evaluation, and no model call. + # See integration-tests/lambda-commonjs/README.md for the AWS role that this job needs. + + release-canary: + name: Published-artifact Lambda canary + runs-on: ubuntu-latest + needs: + - release-please + - release-client + - release-ai-node + - release-ai-otel + - release-openai-messages + if: >- + ${{ always() + && !contains(needs.*.result, 'failure') + && !contains(needs.*.result, 'cancelled') + && vars.AWS_LAMBDA_CANARY_ROLE_ARN != '' + && vars.AWS_LAMBDA_CANARY_PERMISSIONS_BOUNDARY != '' + && (github.event_name != 'workflow_dispatch' || inputs.canary_versions != '') + && (github.event_name == 'workflow_dispatch' + || needs.release-please.outputs.package-client-released == 'true' + || needs.release-please.outputs.package-ai-node-released == 'true' + || needs.release-please.outputs.package-ai-otel-released == 'true' + || needs.release-please.outputs.package-openai-messages-released == 'true') }} + permissions: + contents: read + id-token: write + env: + AWS_REGION: ${{ vars.AWS_LAMBDA_CANARY_REGION || 'us-east-1' }} + CANARY_STAGE: c${{ github.run_id }}-${{ github.run_attempt }} + SLS_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Resolve canary versions + id: versions + env: + REQUESTED_VERSIONS: ${{ inputs.canary_versions }} + run: | + set -euo pipefail + if [ -n "${REQUESTED_VERSIONS:-}" ]; then + resolved="$REQUESTED_VERSIONS" + else + resolved=$(node -e "const m=require('./.release-please-manifest.json');const d={'ai-server':'packages/client','ai-node':'packages/ai-node','openai-messages':'packages/openai-messages','ai-otel':'packages/ai-otel'};process.stdout.write(Object.entries(d).map(([n,p])=>n+'='+m[p]).join(','))") + fi + echo "Canary versions: $resolved" + echo "resolved=$resolved" >> "$GITHUB_OUTPUT" + + - name: Wait for the versions to be visible on npm + env: + LD_AI_CANARY_VERSIONS: ${{ steps.versions.outputs.resolved }} + run: node integration-tests/lambda-commonjs/canary.mjs wait + + # The build runs npm install for the Serverless toolchain, and every install script + # inherits this environment. The build needs no AWS access, so the credentials + # start after it. + - name: Build the canary package + env: + LD_AI_CANARY_VERSIONS: ${{ steps.versions.outputs.resolved }} + run: node integration-tests/lambda-commonjs/canary.mjs build --package --stage="$CANARY_STAGE" + + - name: Configure AWS credentials + id: aws-credentials + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + with: + audience: 'https://github.com/launchdarkly' + role-to-assume: ${{ vars.AWS_LAMBDA_CANARY_ROLE_ARN }} + role-session-name: JsAiSdkLambdaCanary + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy the ephemeral Lambda + working-directory: integration-tests/lambda-commonjs/.canary/app + env: + LD_AI_CANARY_PERMISSIONS_BOUNDARY: ${{ vars.AWS_LAMBDA_CANARY_PERMISSIONS_BOUNDARY }} + run: npx serverless deploy --stage "$CANARY_STAGE" + + - name: Invoke and assert the capability payload + env: + LD_AI_CANARY_VERSIONS: ${{ steps.versions.outputs.resolved }} + run: | + set -euo pipefail + aws lambda invoke \ + --function-name "ld-ai-canary-$CANARY_STAGE" \ + --cli-binary-format raw-in-base64-out \ + --payload '{}' \ + response.json + cat response.json + node integration-tests/lambda-commonjs/canary.mjs assert --response=response.json + + # A failed build stops before the credentials step. Then no AWS resource exists, + # and the cleanup has no credentials to delete one. + - name: Remove the ephemeral Lambda + if: always() && steps.aws-credentials.outcome == 'success' + run: | + set -uo pipefail + status=0 + (cd integration-tests/lambda-commonjs/.canary/app && npx serverless remove --stage "$CANARY_STAGE") || status=1 + log_group="/aws/lambda/ld-ai-canary-$CANARY_STAGE" + if ! delete_output=$(aws logs delete-log-group --log-group-name "$log_group" 2>&1); then + # Stack removal already deletes the log group, so a missing group is a success. + if ! printf '%s' "$delete_output" | grep -q 'ResourceNotFoundException'; then + echo "$delete_output" + status=1 + fi + fi + if [ "$status" -ne 0 ]; then + echo "::error::Canary cleanup failed. Look for a leftover ld-ai-canary-$CANARY_STAGE stack or $log_group log group." + fi + exit "$status" diff --git a/.gitignore b/.gitignore index 7065b80a..52a1ebde 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,6 @@ output/ spec.md tmp/ integration-tests/**/.staging/ +integration-tests/**/.canary/ .claude/ \ No newline at end of file diff --git a/biome.json b/biome.json index 03774cdf..6934fbe2 100644 --- a/biome.json +++ b/biome.json @@ -48,7 +48,8 @@ "!**/node_modules", "!**/*.d.ts", "!**/output", - "!**/.staging" + "!**/.staging", + "!**/.canary" ] }, "overrides": [ diff --git a/integration-tests/lambda-commonjs/README.md b/integration-tests/lambda-commonjs/README.md new file mode 100644 index 00000000..e7386056 --- /dev/null +++ b/integration-tests/lambda-commonjs/README.md @@ -0,0 +1,140 @@ +# Published-artifact Lambda release canary + +Owner: the LaunchDarkly AI SDK team. The team maintains `launchdarkly/js-ai-sdk`. + +## Plain version + +We publish packages to npm. A customer then installs them and runs them on AWS Lambda. + +This test does the same thing after each release. It installs the published packages. +It runs them in a real AWS Lambda function. It then deletes that function. + +If the test fails, the packages are broken for customers. We learn this in minutes, not weeks. + +The test only loads the packages. It uses no SDK key. It evaluates no flag. It calls no model. +So the test costs almost nothing. + +## What it means for the code (junior eng) + +A canary is a small test that runs against production artifacts after a release. +This canary is the second half of the work in +[AIC-3370](https://launchdarkly.atlassian.net/browse/AIC-3370). + +The suite in `integration-tests/commonjs-webpack` packs the workspace with `npm pack`. +That suite tests the tarball that we plan to publish. +This canary tests the artifact that npm actually serves. + +The canary installs exact published versions. It never uses a workspace link. +It never uses a dist-tag such as `latest`. + +The fixture bundles the app with Serverless Framework 3 and Webpack 5. +It also uses `webpack-node-externals` 3 with no allowlist. +So Node resolves the LaunchDarkly packages at runtime. +That path is the exact path that failed in AIC-3370. + +The workflow then deploys one function. It invokes the function one time. +It checks the JSON answer. It then deletes the function and the log group. + +### The driver + +`canary.mjs` holds all of the logic. It takes one exact version for each package. +You pass the versions as flags. You can also set +`LD_AI_CANARY_VERSIONS='ai-server=0.4.0,ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0'`. +The driver rejects a version that is not an exact semver. + +The list includes `@launchdarkly/ai-server`, the core package. +`@launchdarkly/ai-node` depends on the core package with `*`. +So npm picks any visible core version when the canary does not pin it. +The canary installs the core version directly, and a core-only release also runs the canary. + +| Command | What it does | +| --- | --- | +| `wait` | Calls `npm view` until npm serves all four versions. A new publish is not readable at once. | +| `build` | Copies `fixture/` into `.canary/app`. Installs the exact versions with `--save-exact`. Checks the installed tree. Runs `serverless package` when you pass `--package`. | +| `assert` | Reads the invoke payload. Checks `ok`, a `v22.x` runtime, the capability names, and the loaded versions. | + +The function name is `ld-ai-canary-`. The workflow gives each run a unique stage. + +### Local dry run + +This run needs no AWS credentials. + +```bash +cd integration-tests/lambda-commonjs +node canary.mjs build --ai-server=0.3.0 --ai-node=0.2.0 --openai-messages=0.2.0 --ai-otel=0.1.1 --package --stage=local +unzip -l .canary/app/.serverless/ld-ai-canary.zip | grep '@launchdarkly' +``` + +The config schema of Serverless 3 does not know the `nodejs22.x` runtime. +So the package step prints an `Invalid configuration encountered` warning for `provider.runtime`. +The fixture sets `configValidationMode: warn`, and the message stays a warning. +CloudFormation accepts the runtime. The function runs on Node 22. +The canary reads `process.version` and fails on any other runtime. + +`yarn test:integration` runs `canary.test.ts`. +That file tests the assert logic and the exact-version guard. +It uses no network and no AWS account. + +### AWS prerequisite + +The workflow job uses GitHub OIDC only. +OIDC lets the job get short-lived AWS credentials from a role. +This repository holds no long-lived AWS keys. Please do not add any. + +The job stays skipped until these repository variables exist. + +| Variable | Purpose | +| --- | --- | +| `AWS_LAMBDA_CANARY_ROLE_ARN` | The role that the job assumes. The job is skipped while this variable is empty. | +| `AWS_LAMBDA_CANARY_PERMISSIONS_BOUNDARY` | The boundary policy ARN. It must match the ARN in the role's `iam:PermissionsBoundary` condition. The job is skipped while this variable is empty. | +| `AWS_LAMBDA_CANARY_REGION` | Optional. The default is `us-east-1`. | + +The trust policy of the role must allow `token.actions.githubusercontent.com`. +Limit `sub` to `repo:launchdarkly/js-ai-sdk:ref:refs/heads/main`. +The release push runs from `main`, and you start a canary dispatch from `main` too. +A wider `sub` lets a workflow on any branch assume the role. +Set `aud` to `https://github.com/launchdarkly`. + +The role needs these permissions. Scope each one to the `ld-ai-canary-*` resources. + +- `cloudformation:CreateStack`, `UpdateStack`, `DeleteStack`, `DescribeStacks`, `DescribeStackEvents`, + `DescribeStackResource(s)`, `ValidateTemplate`, `GetTemplate`, `ListStackResources` +- `s3:CreateBucket`, `DeleteBucket`, `ListBucket`, `GetObject`, `PutObject`, `DeleteObject`, + `GetBucketLocation`, `GetBucketPolicy`, `PutBucketPolicy`, `PutEncryptionConfiguration` on the + Serverless deployment bucket +- `lambda:CreateFunction`, `DeleteFunction`, `GetFunction`, `GetFunctionConfiguration`, + `UpdateFunctionCode`, `UpdateFunctionConfiguration`, `InvokeFunction`, `ListVersionsByFunction`, + `TagResource` +- `logs:CreateLogGroup`, `DeleteLogGroup`, `DescribeLogGroups`, `PutRetentionPolicy` +- `iam:CreateRole`, `DeleteRole`, `GetRole`, `PassRole`, `PutRolePolicy`, `DeleteRolePolicy` + for the execution role of the function + +The generated stack puts inline `Policies` on the execution role only. +So the role needs no `iam:AttachRolePolicy` and no `iam:DetachRolePolicy`. + +The `iam:CreateRole` and `iam:PutRolePolicy` actions need an `iam:PermissionsBoundary` condition. +That condition stops the role from granting itself more rights than the boundary allows. +The workflow passes `vars.AWS_LAMBDA_CANARY_PERMISSIONS_BOUNDARY` to the fixture. +The fixture reads it as `LD_AI_CANARY_PERMISSIONS_BOUNDARY`. +Set that repository variable to the same boundary policy ARN that the condition names. + +### When the canary fails + +A failure means that a published package does not load from a CommonJS Lambda bundle. +That is the AIC-3370 bug again. + +Run the local dry run above with the published versions. +Then invoke the generated bundle directly. + +The `serverless-webpack` plugin deletes `.webpack/` after it writes the zip. +So you unzip the package first, and then you load `handler.js` from there. + +```bash +cd integration-tests/lambda-commonjs/.canary/app +unzip -o .serverless/ld-ai-canary.zip -d .unzipped +node -e "require('./.unzipped/handler.js').handler().then(console.log, console.error)" +``` + +The cleanup step always runs. The step prints an error when it cannot delete the stack. +Then you delete the `ld-ai-canary-` CloudFormation stack by hand. +You also delete the `/aws/lambda/ld-ai-canary-` log group by hand. diff --git a/integration-tests/lambda-commonjs/canary.mjs b/integration-tests/lambda-commonjs/canary.mjs new file mode 100644 index 00000000..0b4298fd --- /dev/null +++ b/integration-tests/lambda-commonjs/canary.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +// Release canary driver. It stages a clean CommonJS Serverless app. +// The app installs the exact published versions of the LaunchDarkly AI packages. +// The driver waits on npm, builds the app, or checks the payload from a deployed Lambda. +// +// Usage: +// node canary.mjs wait --ai-server=0.4.0 --ai-node=0.3.0 --openai-messages=0.3.0 --ai-otel=0.2.0 +// node canary.mjs build [--package] [--stage=local] +// node canary.mjs assert --response= +// +// Versions may also be supplied as +// LD_AI_CANARY_VERSIONS='ai-server=0.4.0,ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0'. + +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +export const appDir = join(here, '.canary', 'app'); + +// Short name -> published package name. The core package is installed directly, because +// ai-node depends on it with `*` and would otherwise resolve to any visible core version. +const PACKAGES = { + 'ai-server': '@launchdarkly/ai-server', + 'ai-node': '@launchdarkly/ai-node', + 'openai-messages': '@launchdarkly/ai-openai-messages', + 'ai-otel': '@launchdarkly/ai-otel', +}; + +/** Pinned to the Serverless 3 + Webpack 5 toolchain reported in AIC-3370. */ +const TOOLCHAIN = { + serverless: '3.40.0', + 'serverless-webpack': '5.15.4', + webpack: '5.104.1', + 'webpack-cli': '6.0.1', + 'webpack-node-externals': '3.0.0', +}; + +/** The exact type each export must have inside the Lambda. */ +const EXPECTED_CAPABILITIES = { + aiServer: 'object', + config: 'function', + initClient: 'function', + shutdown: 'function', + openaiMessages: 'function', + aiOtel: 'object', +}; + +const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?$/; + +const NPM_WAIT_ATTEMPTS = 30; +const NPM_WAIT_INTERVAL_MS = 10_000; + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function run(command, args, cwd, options = {}) { + const result = spawnSync(command, args, { cwd, encoding: 'utf8', stdio: 'inherit', ...options }); + if (result.error) { + throw result.error; + } + if (!options.allowFailure && result.status !== 0) { + fail(`${command} ${args.join(' ')} failed with status ${result.status}`); + } + return result; +} + +function capture(command, args, cwd) { + return spawnSync(command, args, { cwd, encoding: 'utf8' }); +} + +function parseArgs(argv) { + const args = {}; + for (const arg of argv) { + const match = /^--([^=]+)(?:=(.*))?$/.exec(arg); + if (!match) { + fail(`Unrecognized argument: ${arg}`); + } + args[match[1]] = match[2] ?? 'true'; + } + return args; +} + +/** Resolves one exact version per package. Ranges, dist-tags, and workspace links are rejected. */ +function resolveVersions(args) { + const fromEnv = {}; + for (const pair of (process.env.LD_AI_CANARY_VERSIONS ?? '').split(',')) { + const [name, version] = pair.split('=').map((part) => part.trim()); + if (name && version) { + fromEnv[name] = version; + } + } + + const versions = {}; + for (const [shortName, packageName] of Object.entries(PACKAGES)) { + const version = args[shortName] ?? fromEnv[shortName]; + if (!version) { + fail(`Missing version for ${packageName}. Pass --${shortName}= or set LD_AI_CANARY_VERSIONS.`); + } + if (!EXACT_VERSION.test(version)) { + fail(`${packageName}@${version} is not an exact version. The canary never installs ranges or dist-tags.`); + } + versions[packageName] = version; + } + return versions; +} + +async function waitForNpm(versions) { + for (const [packageName, version] of Object.entries(versions)) { + let visible = false; + for (let attempt = 1; attempt <= NPM_WAIT_ATTEMPTS; attempt += 1) { + const result = capture('npm', ['view', `${packageName}@${version}`, 'version'], here); + if (result.status === 0 && result.stdout.trim() === version) { + visible = true; + process.stdout.write(`${packageName}@${version} is visible on npm\n`); + break; + } + process.stdout.write(`${packageName}@${version} not on npm yet (attempt ${attempt}/${NPM_WAIT_ATTEMPTS})\n`); + await sleep(NPM_WAIT_INTERVAL_MS); + } + if (!visible) { + fail(`${packageName}@${version} never became visible on npm.`); + } + } +} + +function writeApp(versions) { + rmSync(join(here, '.canary'), { recursive: true, force: true }); + mkdirSync(appDir, { recursive: true }); + + for (const file of ['handler.cjs', 'serverless.yml', 'webpack.config.cjs']) { + cpSync(join(here, 'fixture', file), join(appDir, file)); + } + + const manifest = { + name: 'ld-ai-lambda-canary-app', + version: '0.0.0', + private: true, + description: 'Ephemeral CommonJS Lambda that loads the published LaunchDarkly AI packages.', + license: 'Apache-2.0', + dependencies: versions, + devDependencies: TOOLCHAIN, + }; + writeFileSync(join(appDir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); +} + +/** Asserts npm installed the exact versions requested, and records them for the handler to echo. */ +function recordInstalledVersions(versions) { + const listed = capture('npm', ['ls', '--depth=0', '--json'], appDir); + const installed = JSON.parse(listed.stdout || '{}').dependencies ?? {}; + + const resolved = {}; + for (const [packageName, expected] of Object.entries(versions)) { + const actual = installed[packageName]?.version; + if (actual !== expected) { + fail(`${packageName} resolved to ${actual ?? 'nothing'}, expected ${expected}.`); + } + resolved[packageName] = actual; + } + writeFileSync(join(appDir, 'versions.json'), `${JSON.stringify(resolved, null, 2)}\n`); +} + +function build(versions, { packageApp, stage }) { + writeApp(versions); + run('npm', ['install', '--save-exact', '--no-audit', '--no-fund'], appDir); + recordInstalledVersions(versions); + + if (packageApp) { + run('npx', ['serverless', 'package', '--stage', stage], appDir, { + env: { ...process.env, SLS_TELEMETRY_DISABLED: '1' }, + }); + } +} + +function assertResponse(responsePath, versions) { + const payload = JSON.parse(readFileSync(responsePath, 'utf8')); + + if (payload.errorType || payload.errorMessage) { + fail(`Lambda returned an error: ${payload.errorType ?? ''} ${payload.errorMessage ?? ''}`); + } + if (payload.ok !== true) { + fail(`Capability payload did not report ok: ${JSON.stringify(payload)}`); + } + if (!String(payload.runtime ?? '').startsWith('v22.')) { + fail(`Expected a Node 22 runtime, got ${payload.runtime}.`); + } + + for (const [symbol, expectedType] of Object.entries(EXPECTED_CAPABILITIES)) { + const actualType = payload.capabilities?.[symbol]; + if (actualType !== expectedType) { + fail(`Capability ${symbol} resolved to ${actualType ?? 'nothing'}, expected ${expectedType}.`); + } + } + for (const [packageName, expected] of Object.entries(versions)) { + if (payload.versions?.[packageName] !== expected) { + fail(`Lambda loaded ${packageName}@${payload.versions?.[packageName]}, expected ${expected}.`); + } + } + + process.stdout.write(`Canary passed on ${payload.runtime}: ${JSON.stringify(payload.versions)}\n`); +} + +const [command, ...rest] = process.argv.slice(2); +const args = parseArgs(rest); +const versions = resolveVersions(args); + +switch (command) { + case 'wait': + await waitForNpm(versions); + break; + case 'build': + build(versions, { packageApp: args.package === 'true', stage: args.stage ?? 'local' }); + break; + case 'assert': + if (!args.response) { + fail('assert requires --response='); + } + assertResponse(args.response, versions); + break; + default: + fail(`Unknown command: ${command ?? '(none)'}. Expected wait, build, or assert.`); +} diff --git a/integration-tests/lambda-commonjs/canary.test.ts b/integration-tests/lambda-commonjs/canary.test.ts new file mode 100644 index 00000000..5a97e152 --- /dev/null +++ b/integration-tests/lambda-commonjs/canary.test.ts @@ -0,0 +1,118 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const driver = join(here, 'canary.mjs'); + +const VERSIONS = ['--ai-server=0.4.0', '--ai-node=0.3.0', '--openai-messages=0.3.0', '--ai-otel=0.2.0']; + +const PAYLOAD = { + ok: true, + runtime: 'v22.11.0', + versions: { + '@launchdarkly/ai-server': '0.4.0', + '@launchdarkly/ai-node': '0.3.0', + '@launchdarkly/ai-openai-messages': '0.3.0', + '@launchdarkly/ai-otel': '0.2.0', + }, + capabilities: { + aiServer: 'object', + config: 'function', + initClient: 'function', + shutdown: 'function', + openaiMessages: 'function', + aiOtel: 'object', + }, +}; + +function writePayload(payload: unknown): string { + const file = join(mkdtempSync(join(tmpdir(), 'ld-canary-')), 'response.json'); + writeFileSync(file, JSON.stringify(payload)); + return file; +} + +function assertPayload(payload: unknown, versions: string[] = VERSIONS) { + return spawnSync('node', [driver, 'assert', `--response=${writePayload(payload)}`, ...versions], { + encoding: 'utf8', + }); +} + +describe('release canary driver', () => { + it('accepts a capability payload from the expected versions on Node 22', () => { + const result = assertPayload(PAYLOAD); + + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + }); + + it('rejects versions that are not exact', () => { + const result = assertPayload(PAYLOAD, [ + '--ai-server=0.4.0', + '--ai-node=latest', + '--openai-messages=0.3.0', + '--ai-otel=0.2.0', + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('not an exact version'); + }); + + it('rejects a payload built from different versions than were published', () => { + const result = assertPayload({ + ...PAYLOAD, + versions: { ...PAYLOAD.versions, '@launchdarkly/ai-node': '0.2.0' }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('expected 0.3.0'); + }); + + it('rejects a payload from a runtime other than Node 22', () => { + const result = assertPayload({ ...PAYLOAD, runtime: 'v20.11.0' }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Expected a Node 22 runtime'); + }); + + it('rejects a capability the Lambda could not resolve — the AIC-3370 failure mode', () => { + const result = assertPayload({ + ...PAYLOAD, + capabilities: { ...PAYLOAD.capabilities, config: 'undefined' }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Capability config resolved to undefined'); + }); + + it('rejects a callable export that resolves to the wrong type', () => { + const result = assertPayload({ + ...PAYLOAD, + capabilities: { ...PAYLOAD.capabilities, initClient: 'object' }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Capability initClient resolved to object, expected function'); + }); + + it('rejects a payload that drops an expected export', () => { + const { shutdown: _dropped, ...capabilities } = PAYLOAD.capabilities; + const result = assertPayload({ ...PAYLOAD, capabilities }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Capability shutdown resolved to nothing'); + }); + + it('reports a Lambda invocation error instead of passing', () => { + const result = assertPayload({ + errorType: 'Error', + errorMessage: 'No "exports" main defined in @launchdarkly/ai-node/package.json', + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Lambda returned an error'); + }); +}); diff --git a/integration-tests/lambda-commonjs/fixture/handler.cjs b/integration-tests/lambda-commonjs/fixture/handler.cjs new file mode 100644 index 00000000..321219c8 --- /dev/null +++ b/integration-tests/lambda-commonjs/fixture/handler.cjs @@ -0,0 +1,26 @@ +'use strict'; + +// Lambda-shaped CommonJS entry point for the release canary. It only reports which symbols the +// published packages resolved — no LaunchDarkly SDK key, no flag evaluation, no model call. +const versions = require('./versions.json'); + +exports.handler = async () => { + const aiServer = await import('@launchdarkly/ai-server'); + const aiNode = await import('@launchdarkly/ai-node'); + const openaiMessages = await import('@launchdarkly/ai-openai-messages'); + const aiOtel = await import('@launchdarkly/ai-otel'); + + return { + ok: true, + runtime: process.version, + versions, + capabilities: { + aiServer: typeof aiServer, + config: typeof aiNode.config, + initClient: typeof aiNode.initClient, + shutdown: typeof aiNode.shutdown, + openaiMessages: typeof openaiMessages.openaiMessages, + aiOtel: typeof aiOtel, + }, + }; +}; diff --git a/integration-tests/lambda-commonjs/fixture/serverless.yml b/integration-tests/lambda-commonjs/fixture/serverless.yml new file mode 100644 index 00000000..b38aa9fc --- /dev/null +++ b/integration-tests/lambda-commonjs/fixture/serverless.yml @@ -0,0 +1,37 @@ +service: ld-ai-canary +frameworkVersion: '3' + +# Serverless 3's schema predates nodejs22.x; CloudFormation accepts it. +configValidationMode: warn + +provider: + name: aws + runtime: nodejs22.x + region: ${env:AWS_REGION, 'us-east-1'} + stage: ${opt:stage, 'local'} + memorySize: 512 + timeout: 30 + logRetentionInDays: 1 + architecture: x86_64 + iam: + role: + permissionsBoundary: ${env:LD_AI_CANARY_PERMISSIONS_BOUNDARY, null} + stackTags: + owner: launchdarkly-ai-sdk + purpose: js-ai-sdk-release-canary + ephemeral: 'true' + +plugins: + - serverless-webpack + +custom: + webpack: + webpackConfig: webpack.config.cjs + includeModules: true + packager: npm + +functions: + capability: + name: ld-ai-canary-${sls:stage} + handler: handler.handler + description: Loads the published LaunchDarkly AI packages and reports what resolved. diff --git a/integration-tests/lambda-commonjs/fixture/webpack.config.cjs b/integration-tests/lambda-commonjs/fixture/webpack.config.cjs new file mode 100644 index 00000000..710b0dca --- /dev/null +++ b/integration-tests/lambda-commonjs/fixture/webpack.config.cjs @@ -0,0 +1,19 @@ +'use strict'; + +const path = require('node:path'); +const nodeExternals = require('webpack-node-externals'); +const slsw = require('serverless-webpack'); + +// No allowlist: the LaunchDarkly AI packages stay external and are loaded through `require()` +// from the deployment package, which is the resolution path AIC-3370 broke. +module.exports = { + mode: 'none', + target: 'node22', + entry: slsw.lib.entries, + externals: [nodeExternals()], + output: { + path: path.join(__dirname, '.webpack'), + filename: '[name].js', + libraryTarget: 'commonjs2', + }, +}; diff --git a/package.json b/package.json index 3131c4ff..c5fc7964 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build": "yarn workspace @launchdarkly/ai-server run build && yarn workspaces run build", "clean": "yarn workspaces run clean", "test": "vitest run examples/utils.test.ts && yarn workspaces run test", - "test:integration": "vitest run integration-tests/commonjs-webpack/commonjs-webpack.test.ts", + "test:integration": "vitest run integration-tests/commonjs-webpack/commonjs-webpack.test.ts integration-tests/lambda-commonjs/canary.test.ts", "typecheck": "yarn workspaces run typecheck", "format": "biome format --write .", "format:check": "biome format --check .",