From 83ef1308f7c71ab2cd718da5ed469ad78dc69e90 Mon Sep 17 00:00:00 2001 From: Jonathan Sells Date: Sun, 25 Oct 2026 19:47:05 -0700 Subject: [PATCH 1/2] build: add Asana Node 26 builds with pointer compression Standard and FIPS builds configured with --experimental-enable-pointer-compression (multi-cage, V8 sandbox off) and Temporal support, with no Node or V8 source changes. Node 26 needs GCC >= 13.2, newer than Ubuntu 22.04's libstdc++ (the sandbox and remote-execution hosts). Like the official release binaries, these build on RHEL 8 (AlmaLinux 8) with gcc-toolset-14, which keeps the runtime requirements at glibc 2.28, GLIBCXX_3.4.25 and libatomic. The FIPS build compiles against OpenSSL 3.0 LTS built from source and links against the system libssl.so.3 at runtime. Each build is verified before it is published: symbol versions must be available on Ubuntu 22.04, and asana-smoke-test.js must pass with LD_BIND_NOW=1 on stock Ubuntu 22.04 and on Wolfi. Native packages are rebuilt with the same toolchain; @datadog/pprof moves to 5.20.0, the first line with Node 26 (ABI 147) support. Co-authored-by: Cursor --- .github/workflows/build-node-openssl-fips.yml | 108 ++++++++++++ .github/workflows/build-node-packages.yml | 151 +++++++++++++++++ .github/workflows/build-node.yml | 98 +++++++++++ Dockerfile.Node26 | 45 +++++ Dockerfile.Node26fips | 61 +++++++ Dockerfile.Packages | 100 ++++++++++++ asana-smoke-test.js | 154 ++++++++++++++++++ asana-verify-build.sh | 90 ++++++++++ stage_for_s3.bash | 111 +++++++++++++ 9 files changed, 918 insertions(+) create mode 100644 .github/workflows/build-node-openssl-fips.yml create mode 100644 .github/workflows/build-node-packages.yml create mode 100644 .github/workflows/build-node.yml create mode 100644 Dockerfile.Node26 create mode 100644 Dockerfile.Node26fips create mode 100644 Dockerfile.Packages create mode 100644 asana-smoke-test.js create mode 100755 asana-verify-build.sh create mode 100755 stage_for_s3.bash diff --git a/.github/workflows/build-node-openssl-fips.yml b/.github/workflows/build-node-openssl-fips.yml new file mode 100644 index 000000000000..10f1e6858e37 --- /dev/null +++ b/.github/workflows/build-node-openssl-fips.yml @@ -0,0 +1,108 @@ +name: Build Node (FIPS) + +on: + workflow_dispatch: + inputs: + BUILD_REF: + description: 'Git ref to build Node.js from' + required: true + default: 'v26.12.1' + type: string + DOCKER_FILE: + description: 'Dockerfile to use for building Node.js' + required: true + default: 'Dockerfile.Node26fips' + type: string + +permissions: + contents: write + +jobs: + build-node: + name: Build ${{ matrix.platform }}-${{ matrix.arch }} with FIPS OpenSSL + strategy: + fail-fast: false + matrix: + include: + - platform: linux + arch: x64 + runs_on: ubuntu-22.04 + - platform: linux + arch: arm64 + runs_on: ubuntu-22.04-arm + runs-on: ${{ matrix.runs_on }} + + env: + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout Node fork + uses: actions/checkout@v4 + with: + path: node + ref: ${{ inputs.BUILD_REF }} + + - name: Extract Node Version + run: | + NODE_MAJOR_VERSION=$(grep '#define NODE_MAJOR_VERSION' node/src/node_version.h | awk '{print $3}') + NODE_MINOR_VERSION=$(grep '#define NODE_MINOR_VERSION' node/src/node_version.h | awk '{print $3}') + NODE_PATCH_VERSION=$(grep '#define NODE_PATCH_VERSION' node/src/node_version.h | awk '{print $3}') + echo "NODE_VERSION=v${NODE_MAJOR_VERSION}.${NODE_MINOR_VERSION}.${NODE_PATCH_VERSION}" >> $GITHUB_ENV + + - name: Set build metadata + id: meta + working-directory: node + run: | + TIMESTAMP=$(date -u +%Y-%m-%dT%H-%M) + SHORT_SHA=$(git rev-parse --short HEAD) + echo "BUILD_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV + echo "build_id=${TIMESTAMP}-${SHORT_SHA}" >> $GITHUB_OUTPUT + + - name: Execute the Dockerfile + working-directory: node + run: | + docker build -t node_fips_build -f ./${{ inputs.DOCKER_FILE }} . + + - name: Extract resources + run: | + docker create --name temp_node_extract node_fips_build + docker cp temp_node_extract:/usr/src/node/node-install $GITHUB_WORKSPACE/node-install + docker rm temp_node_extract + + - name: Verify build + run: | + bash node/asana-verify-build.sh node-install --fips + + - name: Archive Node + run: | + mkdir -p artifacts + FILENAME=node-${NODE_VERSION}-fips-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.meta.outputs.build_id }}.tar.xz + FILENAME_LATEST=node-${NODE_VERSION}-fips-${{ matrix.platform }}-${{ matrix.arch }}-LATEST.tar.xz + tar -C node-install -cJf artifacts/$FILENAME . + cp artifacts/$FILENAME artifacts/$FILENAME_LATEST + echo "NODE_ARCHIVE=$FILENAME" >> $GITHUB_ENV + echo "NODE_ARCHIVE_LATEST=$FILENAME_LATEST" >> $GITHUB_ENV + + - name: Upload Node archive + uses: actions/upload-artifact@v4 + with: + name: node-${{ env.NODE_VERSION }}-fips-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.meta.outputs.build_id }} + path: artifacts/${{ env.NODE_ARCHIVE }} + + - name: Upload Node archive latest + uses: actions/upload-artifact@v4 + with: + name: node-${{ env.NODE_VERSION }}-fips-${{ matrix.platform }}-${{ matrix.arch }}-LATEST + path: artifacts/${{ env.NODE_ARCHIVE_LATEST }} + + - name: Upload Node archive to release + run: | + TAG="node-${NODE_VERSION}-fips-release" + RELEASE_NAME="node-${NODE_VERSION}-fips-LATEST" + if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + gh release create "$TAG" --title "$RELEASE_NAME" --notes "" --target "$BUILD_SHA" --repo "$REPO" \ + || gh release view "$TAG" --repo "$REPO" >/dev/null + fi + gh release upload "$TAG" "artifacts/${NODE_ARCHIVE_LATEST}" --clobber --repo "$REPO" + gh release edit "$TAG" --title "$RELEASE_NAME" --repo "$REPO" diff --git a/.github/workflows/build-node-packages.yml b/.github/workflows/build-node-packages.yml new file mode 100644 index 000000000000..908ab576b2d9 --- /dev/null +++ b/.github/workflows/build-node-packages.yml @@ -0,0 +1,151 @@ +name: Build Node-Packages + +on: + workflow_dispatch: + inputs: + NODE_REF: + description: 'Branch with the Node source and Dockerfile.Packages (defaults to the ref the workflow runs from)' + required: false + default: '' + type: string + workflow_run: + workflows: ["Build Node (Standard)"] + types: + - completed + branches: + - v26.12.1 + +jobs: + build-packages: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + permissions: + id-token: write + contents: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux + arch: x64 + bazel_arch: amd64 + runs_on: ubuntu-22.04 + - platform: linux + arch: arm64 + bazel_arch: arm64 + runs_on: ubuntu-22.04-arm + runs-on: ${{ matrix.runs_on }} + + env: + PLATFORM: ${{ matrix.platform }} + ARCH: ${{ matrix.arch }} + BAZEL_ARCH: ${{ matrix.bazel_arch }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + # The workflow YAML runs from whichever ref triggered it (main for a workflow_dispatch + # that should upload to S3); that's what the OIDC subject claim binds to, and it's how + # the IAM role's ref_patterns gate works. `ref:` here only controls which tree gets + # checked out into $GITHUB_WORKSPACE. + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.NODE_REF || github.event.workflow_run.head_branch || github.ref_name }} + + - name: Extract Node Version + run: | + NODE_MAJOR_VERSION=$(grep '#define NODE_MAJOR_VERSION' src/node_version.h | awk '{print $3}') + NODE_MINOR_VERSION=$(grep '#define NODE_MINOR_VERSION' src/node_version.h | awk '{print $3}') + NODE_PATCH_VERSION=$(grep '#define NODE_PATCH_VERSION' src/node_version.h | awk '{print $3}') + echo "NODE_VERSION=v${NODE_MAJOR_VERSION}.${NODE_MINOR_VERSION}.${NODE_PATCH_VERSION}" >> $GITHUB_ENV + + - name: Download Node archive + run: | + ASSET="node-${NODE_VERSION}-${PLATFORM}-${ARCH}-LATEST.tar.xz" + gh release download "node-${NODE_VERSION}-release" \ + --repo "$REPO" \ + --pattern "$ASSET" + mv "$ASSET" node.tar.xz + + - name: Execute the Dockerfile + run: | + docker build -t node_packages_build -f Dockerfile.Packages . + + - name: Extract resources + run: | + docker create --name temp_node_packages_extract node_packages_build + docker cp temp_node_packages_extract:/usr/src/node/node_modules $GITHUB_WORKSPACE/node_modules + docker rm temp_node_packages_extract + + - name: Tar node-packages + run: | + # Lay each package out as its rules_js store directory: @. + version_of() { sed -n "s/^ARG $1=//p" Dockerfile.Packages; } + DIRS=() + for spec in "bcrypt BCRYPT_VERSION" "cld CLD_VERSION" "unix-dgram UNIX_DGRAM_VERSION" "@datadog/pprof PPROF_VERSION"; do + set -- $spec + name=$1 + version=$(version_of "$2") + dir="${name//\//+}@${version}" + mkdir -p "$dir/node_modules/$(dirname "$name")" + mv "node_modules/$name" "$dir/node_modules/$name" + DIRS+=("$dir") + done + tar --hard-dereference -cvzf "packages_${ARCH}.tar.gz" "${DIRS[@]}" + + - name: Upload archive to release + run: | + gh release upload "node-${NODE_VERSION}-release" \ + "packages_${ARCH}.tar.gz" \ + --clobber \ + --repo "$REPO" + + # S3 upload is restricted to the protected main branch only. The IAM role + # (push_node_gyp_packages) trusts only refs/heads/main via OIDC. To upload + # packages to S3 after a Node upgrade, trigger workflow_dispatch from main with NODE_REF. + - name: Configure AWS credentials + if: github.ref == 'refs/heads/main' + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: us-east-1 + role-to-assume: arn:aws:iam::403483446840:role/autogen_github_actions_beta_push_node_gyp_packages + + - name: Upload packages to S3 + if: github.ref == 'refs/heads/main' + run: | + # No --acl public-read: the bucket has BucketOwnerEnforced + # (disable_confusing_acls = true), which disables ACLs entirely. + # Reads come via CloudFront OAC. + NODE_MAJOR=$(echo "$NODE_VERSION" | sed 's/^v//' | cut -d. -f1) + SHA256=$(sha256sum "packages_${ARCH}.tar.gz" | awk '{print $1}') + SHORT_HASH=${SHA256:0:8} + S3_KEY="node-gyp/packages_${BAZEL_ARCH}_node${NODE_MAJOR}-${SHORT_HASH}.tar.gz" + echo "Uploading packages_${ARCH}.tar.gz to s3://asana-oss-cache/$S3_KEY" + aws s3 cp "packages_${ARCH}.tar.gz" "s3://asana-oss-cache/$S3_KEY" + { + echo "S3_KEY=$S3_KEY" + echo "SHA256=$SHA256" + echo "NODE_MAJOR=$NODE_MAJOR" + } >> "$GITHUB_ENV" + + - name: Verify upload is reachable via CloudFront + if: github.ref == 'refs/heads/main' + run: | + # Mac Bazel builds rewrite asana-oss-cache.s3.us-east-1.amazonaws.com/* + # to asana-oss-cache.asana.biz/* (CloudFront). If the S3 key prefix isn't + # allowlisted in CloudFront's path_patterns, Bazel fetches will 403. + URL="https://asana-oss-cache.asana.biz/$S3_KEY" + echo "Checking $URL" + if ! curl -fsSI "$URL"; then + echo "CloudFront returned an error for $URL. Check path_patterns in system_packages.tf." + exit 1 + fi + + - name: Print tools_repositories.bzl stanza + if: github.ref == 'refs/heads/main' + run: | + echo "" + echo "=== Update tools_repositories.bzl in codez ===" + echo " name = \"node_gyp_packages_${BAZEL_ARCH}_node${NODE_MAJOR}\"," + echo " urls = [\"https://asana-oss-cache.s3.us-east-1.amazonaws.com/$S3_KEY\"]," + echo " sha256 = \"$SHA256\"," diff --git a/.github/workflows/build-node.yml b/.github/workflows/build-node.yml new file mode 100644 index 000000000000..acaa5e310193 --- /dev/null +++ b/.github/workflows/build-node.yml @@ -0,0 +1,98 @@ +name: Build Node (Standard) + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-node: + name: Build ${{ matrix.platform }}-${{ matrix.arch }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux + arch: x64 + runs_on: ubuntu-22.04 + - platform: linux + arch: arm64 + runs_on: ubuntu-22.04-arm + runs-on: ${{ matrix.runs_on }} + + env: + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout Node fork + uses: actions/checkout@v4 + with: + path: node + ref: ${{ github.ref_name }} + + - name: Extract Node Version + run: | + NODE_MAJOR_VERSION=$(grep '#define NODE_MAJOR_VERSION' node/src/node_version.h | awk '{print $3}') + NODE_MINOR_VERSION=$(grep '#define NODE_MINOR_VERSION' node/src/node_version.h | awk '{print $3}') + NODE_PATCH_VERSION=$(grep '#define NODE_PATCH_VERSION' node/src/node_version.h | awk '{print $3}') + echo "NODE_MAJOR_VERSION=${NODE_MAJOR_VERSION}" >> $GITHUB_ENV + echo "NODE_VERSION=v${NODE_MAJOR_VERSION}.${NODE_MINOR_VERSION}.${NODE_PATCH_VERSION}" >> $GITHUB_ENV + + - name: Set build metadata + id: meta + working-directory: node + run: | + TIMESTAMP=$(date -u +%Y-%m-%dT%H-%M) + SHORT_SHA=$(git rev-parse --short HEAD) + echo "BUILD_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV + echo "build_id=${TIMESTAMP}-${SHORT_SHA}" >> $GITHUB_OUTPUT + + - name: Execute the Dockerfile + working-directory: node + run: | + docker build -t node_build -f ./Dockerfile.Node${NODE_MAJOR_VERSION} . + + - name: Extract resources + run: | + docker create --name temp_node_extract node_build + docker cp temp_node_extract:/usr/src/node/node-install $GITHUB_WORKSPACE/node-install + docker rm temp_node_extract + + - name: Verify build + run: | + bash node/asana-verify-build.sh node-install + + - name: Archive Node + run: | + mkdir -p artifacts + FILENAME=node-${NODE_VERSION}-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.meta.outputs.build_id }}.tar.xz + FILENAME_LATEST=node-${NODE_VERSION}-${{ matrix.platform }}-${{ matrix.arch }}-LATEST.tar.xz + tar -C node-install -cJf artifacts/$FILENAME . + cp artifacts/$FILENAME artifacts/$FILENAME_LATEST + echo "NODE_ARCHIVE=$FILENAME" >> $GITHUB_ENV + echo "NODE_ARCHIVE_LATEST=$FILENAME_LATEST" >> $GITHUB_ENV + + - name: Upload Node archive + uses: actions/upload-artifact@v4 + with: + name: node-${{ env.NODE_VERSION }}-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.meta.outputs.build_id }} + path: artifacts/${{ env.NODE_ARCHIVE }} + + - name: Upload Node archive latest + uses: actions/upload-artifact@v4 + with: + name: node-${{ env.NODE_VERSION }}-${{ matrix.platform }}-${{ matrix.arch }}-LATEST + path: artifacts/${{ env.NODE_ARCHIVE_LATEST }} + + - name: Upload Node archive to release + run: | + TAG="node-${NODE_VERSION}-release" + RELEASE_NAME="node-${NODE_VERSION}-LATEST" + if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + gh release create "$TAG" --title "$RELEASE_NAME" --notes "" --target "$BUILD_SHA" --repo "$REPO" \ + || gh release view "$TAG" --repo "$REPO" >/dev/null + fi + gh release upload "$TAG" "artifacts/${NODE_ARCHIVE_LATEST}" --clobber --repo "$REPO" + gh release edit "$TAG" --title "$RELEASE_NAME" --repo "$REPO" diff --git a/Dockerfile.Node26 b/Dockerfile.Node26 new file mode 100644 index 000000000000..1e12624e4740 --- /dev/null +++ b/Dockerfile.Node26 @@ -0,0 +1,45 @@ +# Builds Asana's Node.js 26 binary: upstream Node with V8 pointer compression. +# +# Node 26 needs GCC >= 13.2, whose own libstdc++ is newer than Ubuntu 22.04's (the sandbox +# and EngFlow remote-execution hosts that run the LunaServer Node toolchain). Like the official +# Node.js release binaries, this builds on RHEL 8 (AlmaLinux 8) with gcc-toolset-14: the +# toolset links the parts of libstdc++ newer than the system's GCC 8 runtime statically, so +# the binary only needs glibc >= 2.28 and GLIBCXX_3.4.25, plus the libatomic runtime. +FROM almalinux:8 AS base + +RUN set -e; \ + for i in 1 2 3 4 5; do \ + echo "Attempt $i: Setting up build environment..." && \ + dnf install -y \ + gcc-toolset-14-gcc \ + gcc-toolset-14-gcc-c++ \ + gcc-toolset-14-libatomic-devel \ + git \ + make \ + python3.12 \ + tar \ + xz \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN dnf clean all + +ENV PATH="/opt/rh/gcc-toolset-14/root/usr/bin:/root/.cargo/bin:$PATH" +ENV CC=gcc CXX=g++ + +# Rust builds temporal_rs (vendored in deps/crates) for the Temporal API, as in official builds. +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --profile minimal --default-toolchain stable && \ + rustc --version && cargo --version + +WORKDIR /usr/src/node +COPY . . + +# Plain --experimental-enable-pointer-compression gives the multi-cage configuration: every +# isolate (main thread and each worker) gets its own 4GB pointer cage, and the V8 sandbox +# stays disabled. Do not add --experimental-pointer-compression-shared-cage: it enables the V8 +# sandbox, which rejects external ArrayBuffer backing stores that native addons create. +RUN ./configure --experimental-enable-pointer-compression --v8-enable-temporal-support +RUN make -j4 install DESTDIR=./node-install + +CMD ["bash"] diff --git a/Dockerfile.Node26fips b/Dockerfile.Node26fips new file mode 100644 index 000000000000..8e34e6db6e5b --- /dev/null +++ b/Dockerfile.Node26fips @@ -0,0 +1,61 @@ +# Builds Asana's FIPS-capable Node.js 26 binary for FedRAMP: the same build as +# Dockerfile.Node26, but dynamically linked against OpenSSL 3 so that the FIPS provider +# installed in the LunaServer FIPS image is used at runtime. +# +# RHEL 8's system OpenSSL is 1.1.1, so this compiles against OpenSSL 3.0 LTS built from +# source. Its libraries export the OPENSSL_3.0.0 symbol version, so the binary links at +# runtime against any OpenSSL 3 libssl.so.3: Ubuntu 22.04's 3.0.2 and Wolfi's 3.x alike. +# +# See Dockerfile.Node26 for why this builds on RHEL 8 with gcc-toolset-14. +FROM almalinux:8 AS base + +RUN set -e; \ + for i in 1 2 3 4 5; do \ + echo "Attempt $i: Setting up build environment..." && \ + dnf install -y \ + gcc-toolset-14-gcc \ + gcc-toolset-14-gcc-c++ \ + gcc-toolset-14-libatomic-devel \ + git \ + make \ + perl \ + python3.12 \ + tar \ + xz \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN dnf clean all + +ENV PATH="/opt/rh/gcc-toolset-14/root/usr/bin:/root/.cargo/bin:$PATH" +ENV CC=gcc CXX=g++ + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --profile minimal --default-toolchain stable && \ + rustc --version && cargo --version + +ARG OPENSSL_VERSION=3.0.22 +ARG OPENSSL_SHA256=67ebca7e50d17383028045486653492195b83db95f8558709701bb47b5c1ef81 +WORKDIR /usr/src/openssl +RUN curl -sSfL -o openssl.tar.gz \ + "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" && \ + echo "${OPENSSL_SHA256} openssl.tar.gz" | sha256sum -c - && \ + tar -xzf openssl.tar.gz --strip-components=1 && \ + ./Configure --prefix=/opt/openssl --libdir=lib shared no-tests && \ + make -j4 && \ + make install_sw +# The build runs the freshly built node (e.g. to generate the startup snapshot). +ENV LD_LIBRARY_PATH=/opt/openssl/lib + +WORKDIR /usr/src/node +COPY . . + +RUN ./configure --shared-openssl \ + --shared-openssl-includes=/opt/openssl/include \ + --shared-openssl-libpath=/opt/openssl/lib \ + --experimental-enable-pointer-compression \ + --v8-enable-temporal-support \ + --openssl-conf-name 'openssl_conf' +RUN make -j4 install DESTDIR=./node-install + +CMD ["bash"] diff --git a/Dockerfile.Packages b/Dockerfile.Packages new file mode 100644 index 000000000000..32357d849819 --- /dev/null +++ b/Dockerfile.Packages @@ -0,0 +1,100 @@ +# Builds the native npm packages that LunaServer loads from node/gyp in codez against +# Asana's pointer-compressed Node build (node.tar.xz, downloaded from this branch's release). +# +# Addons built on the V8/NAN API must be compiled against our headers: prebuilt binaries for +# stock Node have the same NODE_MODULE_VERSION but a different V8 object layout, so they load +# and then crash. N-API addons (bcrypt, cld) are layout-independent but are built here too so +# the tarball stays self-contained. +# +# See Dockerfile.Node26 for why this builds on RHEL 8 with gcc-toolset-14. Package versions must +# match codez's pnpm-lock.yaml, since the tarball is laid over its .aspect_rules_js store. +FROM almalinux:8 AS base + +RUN set -e; \ + for i in 1 2 3 4 5; do \ + echo "Attempt $i: Setting up build environment..." && \ + dnf install -y \ + gcc-toolset-14-gcc \ + gcc-toolset-14-gcc-c++ \ + gcc-toolset-14-libatomic-devel \ + git \ + make \ + python3.12 \ + tar \ + xz \ + && break || \ + (echo "Attempt $i failed! Sleeping 15s..." && sleep 15); \ + done +RUN dnf clean all + +ENV PATH="/opt/rh/gcc-toolset-14/root/usr/bin:$PATH" +ENV CC=gcc CXX=g++ PYTHON=python3.12 npm_config_python=python3.12 + +WORKDIR /usr/src/node +COPY node.tar.xz . + +RUN mkdir -p node-install && tar -C node-install -xJf node.tar.xz + +ENV PATH="/usr/src/node/node-install/usr/local/bin:$PATH" +ENV NODE_DIR="/usr/src/node/node-install/usr/local" + +RUN node -v && node -p "process.arch" && node -p "process.versions.modules" && \ + node -p "process.config.variables.v8_enable_pointer_compression" + +ARG BCRYPT_VERSION=5.1.0 +ARG CLD_VERSION=2.9.1 +ARG UNIX_DGRAM_VERSION=2.0.6 +ARG PPROF_VERSION=5.20.0 + +RUN npm i --nodedir="$NODE_DIR" bcrypt@${BCRYPT_VERSION} +RUN npm i --nodedir="$NODE_DIR" cld@${CLD_VERSION} +RUN npm i --nodedir="$NODE_DIR" unix-dgram@${UNIX_DGRAM_VERSION} + +# Build @datadog/pprof from source against Asana custom Node. +# The npm package ships with no C++ source (only prebuilts for stock Node), so we clone from +# GitHub to build the native binding against our pointer-compressed V8. +RUN npm i @datadog/pprof@${PPROF_VERSION} --ignore-scripts +RUN git clone --branch v${PPROF_VERSION} --depth 1 https://github.com/DataDog/pprof-nodejs.git /usr/src/pprof-source +WORKDIR /usr/src/pprof-source +RUN npm install --unsafe-perm --ignore-scripts +RUN npm install --unsafe-perm --no-save node-gyp +RUN node_modules/.bin/node-gyp rebuild --jobs=max --nodedir="$NODE_DIR" +# Install our binary where node-gyp-build looks first (build/Release), and also over the stock +# prebuild for this ABI so that a loader that skips build/Release cannot pick a stock binary. +WORKDIR /usr/src/node +RUN set -e; \ + ARCH=$(node -p "process.arch"); \ + MODULES_VER=$(node -p "process.versions.modules"); \ + PKG=node_modules/@datadog/pprof; \ + PROFILER_NODE=$(find /usr/src/pprof-source/build/Release -maxdepth 1 -name "*.node" | head -1); \ + echo "Using profiler binary: $PROFILER_NODE"; \ + test -f "$PROFILER_NODE"; \ + mkdir -p "$PKG/build/Release"; \ + cp "$PROFILER_NODE" "$PKG/build/Release/"; \ + REPLACED=0; \ + for f in $(find "$PKG/prebuilds" -path "*linux*${ARCH}*" -name "*.node" | grep -E "(node-|abi)${MODULES_VER}\.node$" | grep -v musl); do \ + echo "Replacing prebuild $f"; cp "$PROFILER_NODE" "$f"; REPLACED=1; \ + done; \ + test "$REPLACED" = 1 || echo "No stock prebuild for ABI ${MODULES_VER}; build/Release only" + +# Load every addon with the custom Node to catch ABI or missing-symbol problems here rather +# than in LunaServer. +RUN node -e " \ + const assert = require('assert'); \ + const bcrypt = require('bcrypt'); \ + assert(bcrypt.compareSync('x', bcrypt.hashSync('x', 4))); \ + assert.strictEqual(typeof require('cld').detect, 'function'); \ + require('unix-dgram').createSocket('unix_dgram').close(); \ + const pprof = require('@datadog/pprof'); \ + pprof.heap.start(512 * 1024, 64); \ + assert(Array.isArray(pprof.heap.profile().sample)); \ + pprof.heap.stop(); \ + pprof.time.start({ durationMillis: 1000 }); \ + const end = Date.now() + 200; while (Date.now() < end) {} \ + assert(Array.isArray(pprof.time.stop().sample)); \ + console.log('All native packages load'); \ +" + +RUN ls node_modules + +CMD ["bash"] diff --git a/asana-smoke-test.js b/asana-smoke-test.js new file mode 100644 index 000000000000..6ee5472e7618 --- /dev/null +++ b/asana-smoke-test.js @@ -0,0 +1,154 @@ +'use strict'; + +// Smoke test for Asana's Node.js builds. Run with the built binary: +// +// node asana-smoke-test.js [--fips] +// +// Exits non-zero if any check fails. The checks cover properties LunaServer depends on +// that the upstream test suite does not assert for our configure flags. + +const assert = require('assert'); +const { AsyncLocalStorage } = require('async_hooks'); +const { spawnSync } = require('child_process'); +const crypto = require('crypto'); +const tls = require('tls'); +const v8 = require('v8'); +const { Worker } = require('worker_threads'); +const zlib = require('zlib'); + +const expectFips = process.argv.includes('--fips'); +const results = []; + +async function check(name, fn) { + try { + const detail = await fn(); + results.push({ name, ok: true, detail }); + } catch (err) { + results.push({ name, ok: false, detail: err?.stack ?? String(err) }); + } +} + +function heapLimitWithMaxOldSpace(mb) { + const out = spawnSync(process.execPath, [ + `--max-old-space-size=${mb}`, + '-p', + 'require("v8").getHeapStatistics().heap_size_limit', + ], { encoding: 'utf8' }); + assert.strictEqual(out.status, 0, out.stderr); + return Number(out.stdout.trim()); +} + +async function main() { + const vars = process.config.variables; + + await check('version matches the source tree', () => { + assert.strictEqual(process.versions.node.split('.')[0], process.env.EXPECTED_NODE_MAJOR); + return `${process.version} (modules ${process.versions.modules}, v8 ${process.versions.v8})`; + }); + + await check('pointer compression enabled, V8 sandbox disabled', () => { + assert.strictEqual(vars.v8_enable_pointer_compression, 1); + assert.strictEqual(vars.v8_enable_31bit_smis_on_64bit_arch, 1); + assert.strictEqual(vars.v8_enable_sandbox, 0); + assert.strictEqual(vars.v8_enable_pointer_compression_shared_cage, 0); + return 'multi-cage pointer compression'; + }); + + await check('heap is capped by the 4GB pointer compression cage', () => { + const limit = heapLimitWithMaxOldSpace(8192); + assert(limit <= 4.5 * 1024 ** 3, `heap_size_limit ${limit} exceeds the 4GB cage`); + return `heap_size_limit with --max-old-space-size=8192: ${(limit / 1024 ** 3).toFixed(2)} GiB`; + }); + + await check('LunaServer startup flags are accepted', () => { + const flags = ['--stack_trace_limit=1000', '--no-wasm-code-gc', '--expose-gc', + '--max-old-space-size=2000', '--max-semi-space-size=64']; + const out = spawnSync(process.execPath, [...flags, '-e', 'gc()'], { encoding: 'utf8' }); + assert.strictEqual(out.status, 0, out.stderr); + return flags.join(' '); + }); + + await check('Temporal support matches the build', () => { + return `v8_enable_temporal_support=${vars.v8_enable_temporal_support}, ` + + `globalThis.Temporal: ${typeof globalThis.Temporal}`; + }); + + await check('Maglev is enabled', () => { + assert.strictEqual(vars.v8_enable_maglev, 1); + }); + + await check('OpenSSL linkage matches the build flavor', () => { + assert.strictEqual(Boolean(vars.node_shared_openssl), expectFips); + return `openssl ${process.versions.openssl}, shared=${vars.node_shared_openssl}, ` + + `getFips()=${crypto.getFips()}`; + }); + + await check('brotli rejects invalid block switches (CVE-2026-111497)', () => { + const invalid = Buffer.from('200010414141fcffff07002228f00b60f7f41f000000', 'hex'); + assert.strictEqual(zlib.constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, -17); + assert.throws(() => zlib.brotliDecompressSync(invalid), + { code: 'ERR__ERROR_FORMAT_BLOCK_SWITCH' }); + }); + + await check('worker threads get their own heap', async () => { + const used = await new Promise((resolve, reject) => { + const worker = new Worker(` + const { parentPort } = require('worker_threads'); + const chunks = []; + for (let i = 0; i < 64; i++) chunks.push(new Array(256 * 1024).fill(i)); + parentPort.postMessage(require('v8').getHeapStatistics().used_heap_size); + `, { eval: true }); + worker.once('message', resolve); + worker.once('error', reject); + }); + return `worker used_heap_size ${(used / 1024 ** 2).toFixed(0)} MiB`; + }); + + await check('WebAssembly memory can be reserved', async () => { + // (module (memory (export "m") 1 65536)) + const bytes = Buffer.from('0061736d010000000506010101808004070501016d0200', 'hex'); + const { instance } = await WebAssembly.instantiate(bytes); + instance.exports.m.grow(16); + return `${instance.exports.m.buffer.byteLength} bytes`; + }); + + await check('full ICU is available', () => { + const formatted = new Intl.DateTimeFormat('ja-JP', { month: 'long' }).format(new Date(0)); + assert.match(formatted, /月/); + return formatted; + }); + + await check('AsyncLocalStorage propagates across await and timers', async () => { + const als = new AsyncLocalStorage(); + const seen = await als.run('ctx', async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + await null; + return new Promise((resolve) => setImmediate(() => resolve(als.getStore()))); + }); + assert.strictEqual(seen, 'ctx'); + }); + + await check('crypto and TLS basics', () => { + const digest = crypto.createHash('sha256').update('asana').digest('hex'); + assert.strictEqual(digest.length, 64); + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const signature = crypto.sign('sha256', Buffer.from('x'), privateKey); + assert(crypto.verify('sha256', Buffer.from('x'), publicKey, signature)); + tls.createSecureContext({ minVersion: 'TLSv1.2' }); + }); + + await check('v8.serialize round-trips', () => { + const value = { a: [1, 2, 3], b: new Map([['k', 'v']]), c: 2n ** 70n }; + assert.deepStrictEqual(v8.deserialize(v8.serialize(value)), value); + }); + + let failed = 0; + for (const { name, ok, detail } of results) { + if (!ok) failed++; + console.log(`${ok ? 'PASS' : 'FAIL'}: ${name}${detail ? `\n ${detail}` : ''}`); + } + console.log(failed ? `\n${failed} check(s) failed` : '\nAll checks passed'); + process.exitCode = failed ? 1 : 0; +} + +main(); diff --git a/asana-verify-build.sh b/asana-verify-build.sh new file mode 100755 index 000000000000..1c018c82de15 --- /dev/null +++ b/asana-verify-build.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Verifies an Asana Node.js build before it is published: +# 1. The binary only needs glibc/libstdc++ symbol versions available on Ubuntu 22.04, +# the oldest platform that runs the LunaServer Node toolchain (sandboxes and EngFlow +# remote-execution workers). +# 2. asana-smoke-test.js passes on stock Ubuntu 22.04 and on Wolfi, the base of the +# LunaServer images, with every symbol resolved at load time (LD_BIND_NOW). +# +# Usage: asana-verify-build.sh [--fips] +# +# is the DESTDIR passed to `make install`, i.e. it contains usr/local/bin/node. +# Requires docker and binutils. + +set -euo pipefail + +INSTALL_DIR=$(cd "$1" && pwd) +FLAVOR=${2:-} +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +NODE_BIN="$INSTALL_DIR/usr/local/bin/node" +EXPECTED_NODE_MAJOR=$(grep '#define NODE_MAJOR_VERSION' "$SCRIPT_DIR/src/node_version.h" | awk '{print $3}') + +MAX_GLIBC=2.35 +MAX_GLIBCXX=3.4.30 +MAX_CXXABI=1.3.13 +MAX_OPENSSL=3.0.0 + +SMOKE_ARGS="" +UBUNTU_PACKAGES="" +WOLFI_PACKAGES="libstdc++ libgcc" +if [ "$FLAVOR" = "--fips" ]; then + SMOKE_ARGS="--fips" + UBUNTU_PACKAGES="libssl3" + WOLFI_PACKAGES="$WOLFI_PACKAGES libssl3 libcrypto3" +fi + +test -x "$NODE_BIN" || { echo "ERROR: $NODE_BIN not found" >&2; exit 1; } + +max_symbol_version() { + objdump -T "$NODE_BIN" | grep -o "$1_[0-9][0-9.]*" | sed "s/^$1_//" | sort -uV | tail -1 +} + +check_symbol_version() { + local prefix=$1 limit=$2 found + found=$(max_symbol_version "$prefix") + echo "Highest $prefix symbol version required: ${found:-none} (limit $limit)" + if [ -n "$found" ] && [ "$(printf '%s\n%s\n' "$found" "$limit" | sort -V | tail -1)" != "$limit" ]; then + echo "ERROR: node requires ${prefix}_$found, newer than ${prefix}_$limit on Ubuntu 22.04" >&2 + objdump -T "$NODE_BIN" | grep "${prefix}_$found" | head -20 >&2 + exit 1 + fi +} + +echo "=== Dynamic dependencies ===" +objdump -p "$NODE_BIN" | grep NEEDED + +echo "=== Symbol versions ===" +check_symbol_version GLIBC "$MAX_GLIBC" +check_symbol_version GLIBCXX "$MAX_GLIBCXX" +check_symbol_version CXXABI "$MAX_CXXABI" +if [ "$FLAVOR" = "--fips" ]; then + # Ubuntu 22.04 ships OpenSSL 3.0.2. + check_symbol_version OPENSSL "$MAX_OPENSSL" +fi + +if objdump -p "$NODE_BIN" | grep NEEDED | grep -q libatomic; then + UBUNTU_PACKAGES="$UBUNTU_PACKAGES libatomic1" + WOLFI_PACKAGES="$WOLFI_PACKAGES libatomic" +fi + +run_smoke_test() { + local image=$1 setup=$2 + echo "=== Smoke test on $image ===" + docker run --rm \ + -v "$INSTALL_DIR:/opt/node:ro" \ + -v "$SCRIPT_DIR/asana-smoke-test.js:/opt/asana-smoke-test.js:ro" \ + -e LD_BIND_NOW=1 \ + -e EXPECTED_NODE_MAJOR="$EXPECTED_NODE_MAJOR" \ + "$image" \ + sh -c "set -e; $setup /opt/node/usr/local/bin/node /opt/asana-smoke-test.js $SMOKE_ARGS" +} + +UBUNTU_SETUP="" +if [ -n "${UBUNTU_PACKAGES// /}" ]; then + UBUNTU_SETUP="apt-get update -qq && apt-get install -y -qq --no-install-recommends $UBUNTU_PACKAGES >/dev/null;" +fi +run_smoke_test ubuntu:22.04 "$UBUNTU_SETUP" +run_smoke_test cgr.dev/chainguard/wolfi-base "apk add --no-cache -q $WOLFI_PACKAGES;" + +echo "=== Build verified ===" diff --git a/stage_for_s3.bash b/stage_for_s3.bash new file mode 100755 index 000000000000..39da948ba346 --- /dev/null +++ b/stage_for_s3.bash @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# +# Downloads this branch's release artifacts (standard + FIPS Node builds and the native +# packages) and stages them under a directory that mirrors their s3://asana-oss-cache keys. +# Every file gets a unique suffix so uploading never overwrites an existing object (the +# bucket is not versioned). Prints the codez Starlark to point at the new files. +# +# Usage: ./stage_for_s3.bash (run from the root of this repository) + +set -euo pipefail + +NODE_MAJOR=$(grep '#define NODE_MAJOR_VERSION' src/node_version.h | awk '{print $3}') +NODE_MINOR=$(grep '#define NODE_MINOR_VERSION' src/node_version.h | awk '{print $3}') +NODE_PATCH=$(grep '#define NODE_PATCH_VERSION' src/node_version.h | awk '{print $3}') +VERSION="${NODE_MAJOR}.${NODE_MINOR}.${NODE_PATCH}" +REPO=Asana/node + +DOWNLOAD_DIR=$(mktemp -d) +trap 'rm -rf "$DOWNLOAD_DIR"' EXIT + +gh release download -R "$REPO" "node-v${VERSION}-release" -D "$DOWNLOAD_DIR" -p "*.tar.xz" -p "packages_*.tar.gz" +gh release download -R "$REPO" "node-v${VERSION}-fips-release" -D "$DOWNLOAD_DIR" -p "*.tar.xz" + +ARCHIVE_HASH=$(cat "$DOWNLOAD_DIR"/*.tar.xz | shasum -a 1 | cut -c1-4) +UNIQUE="pc-$(date '+%Y%m%d.%H%M')-${ARCHIVE_HASH}" +STAGE="node-v${VERSION}-${UNIQUE}" +mkdir -p "$STAGE/node/fork/v${VERSION}" "$STAGE/node-fips/v${VERSION}" "$STAGE/node-gyp" + +# Repackage each build so the archive has a single top-level node-v-[fips-]linux- +# directory, the layout rules_nodejs expects, instead of the usr/local DESTDIR layout. +for flavor in "" "fips-"; do + key_prefix=${flavor:+fips_} + for arch in x64 arm64; do + base="node-v${VERSION}-${flavor}linux-${arch}" + src="$DOWNLOAD_DIR/${base}-LATEST.tar.xz" + test -f "$src" || { echo "ERROR: missing $(basename "$src") in the release" >&2; exit 1; } + + work=$(mktemp -d) + mkdir "$work/$base" + tar -xJf "$src" -C "$work/$base" + if [ -d "$work/$base/usr/local" ]; then + mv "$work/$base"/usr/local/* "$work/$base" + rm -rf "${work:?}/$base/usr" + fi + + if [ -z "$flavor" ]; then dest_dir="$STAGE/node/fork/v${VERSION}"; else dest_dir="$STAGE/node-fips/v${VERSION}"; fi + file="${base}-${UNIQUE}.tar.xz" + tar -C "$work" -cJf "$dest_dir/$file" "$base" + rm -rf "$work" + + printf -v "FILE_${key_prefix}${arch}" %s "$file" + printf -v "SHA_${key_prefix}${arch}" %s "$(shasum -a 256 "$dest_dir/$file" | cut -d' ' -f1)" + done +done + +for arch in x64 arm64; do + if [ "$arch" = x64 ]; then bazel_arch=amd64; else bazel_arch=arm64; fi + src="$DOWNLOAD_DIR/packages_${arch}.tar.gz" + if [ ! -f "$src" ]; then + echo "WARNING: packages_${arch}.tar.gz is not in the release; run build-node-packages.yml first" + continue + fi + sha=$(shasum -a 256 "$src" | cut -d' ' -f1) + file="packages_${bazel_arch}_node${NODE_MAJOR}-${sha:0:8}.tar.gz" + cp "$src" "$STAGE/node-gyp/$file" + printf -v "FILE_packages_${bazel_arch}" %s "$file" + printf -v "SHA_packages_${bazel_arch}" %s "$sha" +done + +echo "" +echo "Staged files (upload with: aws s3 cp --recursive $STAGE/ s3://asana-oss-cache/):" +(cd "$STAGE" && find . -type f | sort) + +# shellcheck disable=SC2154 # FILE_* and SHA_* are assigned with printf -v above. +cat < Date: Sun, 25 Oct 2026 22:21:08 -0700 Subject: [PATCH 2/2] build: pin @datadog/pprof 5.15.1 for pointer-compressed Node 26 Co-authored-by: Cursor --- Dockerfile.Packages | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile.Packages b/Dockerfile.Packages index 32357d849819..23ff5be79e5a 100644 --- a/Dockerfile.Packages +++ b/Dockerfile.Packages @@ -44,7 +44,10 @@ RUN node -v && node -p "process.arch" && node -p "process.versions.modules" && \ ARG BCRYPT_VERSION=5.1.0 ARG CLD_VERSION=2.9.1 ARG UNIX_DGRAM_VERSION=2.0.6 -ARG PPROF_VERSION=5.20.0 +# pprof 5.16.0+ adds otel-thread-ctx.cc, which static_asserts that V8 was built without pointer +# compression. 5.15.1 is the newest release that compiles here and still supports Node 26; codez +# must pin it with dd-trace 5.110.x-5.116.x or a pnpm override. +ARG PPROF_VERSION=5.15.1 RUN npm i --nodedir="$NODE_DIR" bcrypt@${BCRYPT_VERSION} RUN npm i --nodedir="$NODE_DIR" cld@${CLD_VERSION}