Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parse App errors from CloudExecutables that use sub-shell #32997

Open
mrgrain opened this issue Jan 17, 2025 · 2 comments
Open

Parse App errors from CloudExecutables that use sub-shell #32997

mrgrain opened this issue Jan 17, 2025 · 2 comments
Assignees
Labels
@aws-cdk/toolkit effort/medium Medium work item – several days of effort feature-request A feature should be added or improved. p1

Comments

@mrgrain
Copy link
Contributor

mrgrain commented Jan 17, 2025

Errors thrown by CDK Apps are just printed to stdout/stderr by the subprocess. we should intercept, capture and re-throw the error. Instead this should be thrown by toolkit.synth directly.

We need to pass a magic env variable CDK_SERIALIZE_ERRORS to the cdk app. When this variable is set, then we add the following snippet:

process.on('uncaughtException', function(err) {
  // do magic here, e.g.
  console.error('Caught exception: ' + err);
});

This needs to somehow announce to the parent process that the app is no sending a JSON serialized error through the channel.

In the CLI/Toolkit we need to detect this and NOT print regularly. Instead we deserialize the error, recreate it to a proper Node exception and re throw it (toolkit). In the CLI we can also add some nice formatting on the error.

@ashishdhingra ashishdhingra added feature-request A feature should be added or improved. effort/medium Medium work item – several days of effort labels Jan 21, 2025
@mrgrain mrgrain self-assigned this Jan 24, 2025
@mrgrain mrgrain added p1 and removed p2 labels Jan 27, 2025
@mrgrain
Copy link
Contributor Author

mrgrain commented Jan 30, 2025

Option A: Serialize and deserialize errors through a file

The user's TypeScript app is unchanged and executed through tsx. We are using a prelude file to capture any uncaught exceptions. Inside the uncaught exception handler, we serialize the exception content as json and write it to a well-know log file location. The path of that file is provided to the prelude via an env variable.

On the Toolkit side, we can expect the app failure. In that case, the Cloud Assembly Source can read the log file and deserialize the error into a usable form and re-throw it.

Instead of a log file, this could also use a stream and/or numbered file descriptor.

Required changes to the CDK code

For all known CDK lib errors (currently only ValidationError), we should add toJSON() and fromJSON() helpers so that deserialization is straight forward for an integrator.

Usage

try {
  const cdk = new Toolkit();
  const cx = await cdk.fromTypescriptCdkApp("path/to/user/app.ts");
  await cdk.synth(cx);
} catch (error: any) {
  console.error(error.name, error.message);
}

Library code

typescript-app.ts

import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { type CdkAppSourceProps, type ICloudAssemblySource, Toolkit } from "@aws-cdk/toolkit";
import { createRequire } from "node:module";

export class TypeScriptCdkAppToolkit extends Toolkit {
  async fromTypescriptCdkApp(entrypoint: string, props: CdkAppSourceProps) {
    const requireFunc = createRequire(import.meta.dirname);
    const tsx = requireFunc.resolve('.bin/tsx');
    const prelude = requireFunc.resolve('./error-aware-prelude.ts');
    const app = await this.fromCdkApp(`${tsx} -r ${prelude} ${entrypoint}`, props)
    return new TypeScriptCdkApp(app);
  }
}

class TypeScriptCdkApp implements ICloudAssemblySource {
  private readonly source: ICloudAssemblySource;

  constructor(source: ICloudAssemblySource) {
    this.source = source;
  }

  async produce() {
    // We are serializing errors into a temporary log file
    const errorFolder = await fs.mkdtemp(path.join(os.tmpdir(), "cdk-errors-"));
    const errorLog = path.join(errorFolder, "cdk-errors.log");
    process.env.CDK_ERROR_LOG = errorLog;

    try {
      // attempt to run the app
      return await this.source.produce();
    } catch (originalError) {
      // we have encountered an error!
      // let's deserialize the error from the log file
      const errorLogContents = await fs.readFile(errorLog, { encoding: "utf-8" });
      const lastLine = errorLogContents.split("\n").reverse().find(line => line.trim().length > 0) || '';
      let errorObject;
      try {
        errorObject = JSON.parse(lastLine);
      } catch {
        // if we can't serialize the error => throw the original error
        throw originalError;
      }
      throw errorObject;
    } finally {
      // cleanup the environment
      delete process.env.CDK_ERROR_LOG;
      await fs.rm(errorLog, { force: true });
    }
  }
}

error-aware-prelude.ts

if (process.env.CDK_ERROR_LOG) {
  process.on("uncaughtException", function (err) {
    const errorJson = JSON.stringify({
      name: err.name,
      message: err.message,
      stack: err.stack,
    });

    require("fs").writeFileSync(process.env.CDK_ERROR_LOG, errorJson + "\n", { flag: "a" });
    process.exitCode = 1;
    process.exit();
  });
}

@mrgrain
Copy link
Contributor Author

mrgrain commented Jan 30, 2025

Option B: Dynamic TypeScript import

We use tsx's dynamic tsImport() function to directly import the user's TypeScript app. This approach will emit all exceptions directly to the Toolkit code.

Longer term, this might become possible in vanilla Node.js through the work done on built-in TypeScript support.

Implications on the execution context need to be considered, since the userland code is now executing in the same virtual machine as the Toolkit caller. This might be okay, but an approach using vm could potentially increase context separation.

Usage

import { Toolkit } from '@aws-cdk/toolkit';
import { tsImport } from 'tsx/esm/api'
import { CloudAssembly } from 'aws-cdk-lib/cx-api';

try {
  const outdir = 'cdk.out.custom';
  const cdk = new Toolkit();
  const cx = await cdk.fromAssemblyBuilder(async () => {
    await tsImport('./app.ts', import.meta.url)
    return new CloudAssembly(outdir);
  }, { outdir });
  await cdk.synth(cx);
} catch (error: any) {
  console.error(error.name, error.message);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
@aws-cdk/toolkit effort/medium Medium work item – several days of effort feature-request A feature should be added or improved. p1
Projects
None yet
Development

No branches or pull requests

2 participants