From b12d2ea9cb76b86ce95cee1312dcbbfa56aa2eef Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 23 Oct 2026 22:02:07 +0800 Subject: [PATCH] fix: keep temporary Node binaries out of image layers --- .github/workflows/release-readiness.yml | 23 +++++++++++++ docker/Dockerfile | 8 ++--- docker/Dockerfile.310 | 8 ++--- scripts/verify-image-node-layers.py | 44 +++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 scripts/verify-image-node-layers.py diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml index 70c74e6f..1a282830 100644 --- a/.github/workflows/release-readiness.yml +++ b/.github/workflows/release-readiness.yml @@ -9,6 +9,7 @@ on: - 'docker/Dockerfile*' - 'shell/node-runtime-probe.cjs' - 'scripts/verify-panel-package.cjs' + - 'scripts/verify-image-node-layers.py' - 'test/back/node-runtime-probe.test.cjs' workflow_dispatch: @@ -132,6 +133,28 @@ jobs: docker run --rm --network none --entrypoint node \ qinglong-readiness:${{ matrix.image }} \ -e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")' + - name: Export ARMv6 image layers for Node regression checks + if: startsWith(matrix.image, 'alpine') + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.file }} + platforms: linux/arm/v6 + build-args: | + QL_URL=${{ steps.source.outputs.url }} + QL_BRANCH=${{ steps.source.outputs.branch }} + SOURCE_COMMIT=${{ steps.source.outputs.sha }} + outputs: type=docker,dest=${{ runner.temp }}/node-layers-arm.tar + push: false + provenance: false + sbom: false + - name: Check Node binaries in every serialized image layer + if: startsWith(matrix.image, 'alpine') + run: | + docker save qinglong-readiness:${{ matrix.image }} -o "$RUNNER_TEMP/node-layers-amd64.tar" + python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-amd64.tar" amd64 + python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-arm.tar" arm + rm "$RUNNER_TEMP/node-layers-amd64.tar" "$RUNNER_TEMP/node-layers-arm.tar" - name: Export native image for isolated dev validation if: matrix.image == 'debian' || matrix.image == 'alpine' run: | diff --git a/docker/Dockerfile b/docker/Dockerfile index 45f8b333..b95baf6e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,7 +29,6 @@ FROM python:3.11-alpine ARG TARGETARCH ARG TARGETVARIANT -COPY --from=node-runtime-amd64 /usr/local/bin/node /tmp/node-runtime-amd64 ARG QL_MAINTAINER="whyour" LABEL maintainer="${QL_MAINTAINER}" @@ -51,7 +50,8 @@ EXPOSE 5700 COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=builder /opt/node-global/bin/. /usr/local/bin/ -RUN set -x \ +RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \ + set -x \ && apk update -f \ && apk upgrade \ && apk --no-cache add -f bash \ @@ -70,9 +70,9 @@ RUN set -x \ unzip \ npm \ && if [ "$TARGETARCH" = amd64 ]; then \ - mv /tmp/node-runtime-amd64 /usr/local/bin/node; \ + cp /tmp/node-runtime-amd64 /usr/local/bin/node; \ ln -sf /usr/local/bin/node /usr/bin/node; \ - else rm /tmp/node-runtime-amd64; fi \ + fi \ && rm -rf /var/cache/apk/* \ && apk update \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ diff --git a/docker/Dockerfile.310 b/docker/Dockerfile.310 index 0eca1e80..eea74383 100644 --- a/docker/Dockerfile.310 +++ b/docker/Dockerfile.310 @@ -29,7 +29,6 @@ FROM python:3.10-alpine ARG TARGETARCH ARG TARGETVARIANT -COPY --from=node-runtime-amd64 /usr/local/bin/node /tmp/node-runtime-amd64 ARG QL_MAINTAINER="whyour" LABEL maintainer="${QL_MAINTAINER}" @@ -51,7 +50,8 @@ EXPOSE 5700 COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=builder /opt/node-global/bin/. /usr/local/bin/ -RUN set -x \ +RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \ + set -x \ && apk update -f \ && apk upgrade \ && apk --no-cache add -f bash \ @@ -70,9 +70,9 @@ RUN set -x \ unzip \ npm \ && if [ "$TARGETARCH" = amd64 ]; then \ - mv /tmp/node-runtime-amd64 /usr/local/bin/node; \ + cp /tmp/node-runtime-amd64 /usr/local/bin/node; \ ln -sf /usr/local/bin/node /usr/bin/node; \ - else rm /tmp/node-runtime-amd64; fi \ + fi \ && rm -rf /var/cache/apk/* \ && apk update \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ diff --git a/scripts/verify-image-node-layers.py b/scripts/verify-image-node-layers.py new file mode 100644 index 00000000..cc9838b5 --- /dev/null +++ b/scripts/verify-image-node-layers.py @@ -0,0 +1,44 @@ +"""Check serialized Docker layers, including files hidden by later deletions.""" + +import hashlib +import json +import sys +import tarfile + +archive, architecture = sys.argv[1:] +assert architecture in {"amd64", "arm"} +nodes = [] +with tarfile.open(archive) as image: + manifests = json.load(image.extractfile("manifest.json")) + assert len(manifests) == 1, "Expected one platform image" + manifest = manifests[0] + config = json.load(image.extractfile(manifest["Config"])) + assert config["architecture"] == architecture + for name in manifest["Layers"]: + with tarfile.open(fileobj=image.extractfile(name), mode="r|*") as layer: + for member in layer: + filename = member.name.removeprefix("./") + assert not ( + filename == "tmp/node-runtime-amd64" and member.size > 0 + ), "Temporary amd64 Node remains in an immutable layer" + if filename not in {"usr/local/bin/node", "usr/bin/node"}: + continue + if not member.isfile(): + continue + with layer.extractfile(member) as binary: + header = binary.read(20) + assert header[:4] == b"\x7fELF", "Expected a native Node binary" + digest = hashlib.sha256(header) + while chunk := binary.read(1024 * 1024): + digest.update(chunk) + endian = "little" if header[5] == 1 else "big" + machine = int.from_bytes(header[18:20], endian) + assert machine == {"amd64": 62, "arm": 40}[architecture], ( + "Wrong Node architecture in an image layer" + ) + nodes.append({"path": filename, "size": member.size, "sha256": digest.hexdigest()}) +assert len(nodes) == 1, "Expected exactly one retained Node binary across all layers" +assert nodes[0]["path"] == ( + "usr/local/bin/node" if architecture == "amd64" else "usr/bin/node" +) +print(json.dumps({"pass": True, "architecture": architecture, "layers": len(manifest["Layers"]), "nodes": nodes}))