From f5bf831799755562de56f4002f1d8a6ed8b31759 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 29 Jun 2026 16:19:35 -0700 Subject: [PATCH 1/3] feat: add build-tools sysroot and runner.topology for ARC/DinD Add support for ARC/DinD deployments where the runner and Docker daemon have separate filesystems and no root access is available in the workflow. Key changes: - New containers/build-tools/Dockerfile: Ubuntu 22.04 image with build-essential, gcc, g++, make, cmake, dev libraries, and system utilities. Provides the chroot base that the agent needs on ARC/DinD. - New runner.topology config field (standard | arc-dind): Single declarative knob that activates sysroot staging, network isolation defaults, and tool cache validation for ARC/DinD. - New runner.sysrootImage config field: Override the default build-tools image for custom sysroot setups. - Compose generator emits sysroot-stage init service when topology is arc-dind: copies build-tools image FS into a named volume via cp -a, agent mounts it read-only at /host. - Tool cache warning when RUNNER_TOOL_CACHE is under /opt on ARC/DinD (invisible to the DinD daemon filesystem). - Release workflow: new build-build-tools job builds and publishes the build-tools image to GHCR with cosign signing and SBOM. - Updated docs/arc-dind.md with runner.topology usage, build-tools walkthrough, and tool cache redirection guidance. - 32 new tests covering sysroot service, config mapping, schema validation, and compose generation. Closes #5693 Refs: gh-aw#42368, #5541, #5591 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 79 +++++++++++++++- containers/build-tools/Dockerfile | 96 +++++++++++++++++++ docs/arc-dind.md | 89 +++++++++++++++++- docs/awf-config.schema.json | 19 ++++ schemas/token-usage.schema.json | 70 +++++++++++--- src/awf-config-schema.json | 19 ++++ src/compose-generator.test.ts | 77 +++++++++++++++ src/compose-generator.ts | 45 +++++++++ src/config-file-mapping.test.ts | 17 ++++ src/config-file-validation.test.ts | 36 +++++++ src/config-file.ts | 4 + src/config-mapper.ts | 3 + src/services/sysroot-service.test.ts | 136 +++++++++++++++++++++++++++ src/services/sysroot-service.ts | 73 ++++++++++++++ src/types/docker.ts | 9 ++ src/types/platform-options.ts | 30 +++++- 16 files changed, 784 insertions(+), 18 deletions(-) create mode 100644 containers/build-tools/Dockerfile create mode 100644 src/services/sysroot-service.test.ts create mode 100644 src/services/sysroot-service.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93398f04..7d2a98be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -424,10 +424,87 @@ jobs: echo "::warning::SBOM attestation for agent-act failed after 3 attempts (Rekor may be unavailable)" exit 1 + # Build build-tools sysroot image for ARC/DinD deployments + # Provides system-level build infrastructure (gcc, make, dev libraries) for agent containers + build-build-tools: + name: Build Build-Tools Image + runs-on: ubuntu-latest + needs: bump-version + outputs: + digest: ${{ steps.build_build_tools.outputs.digest }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + with: + ref: ${{ needs.bump-version.outputs.version }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@a3c050c5b9001e95079e262a6db77c5e7c7c3467 # v3 + with: + platforms: linux/arm64 + + - name: Install cosign + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0 + + - name: Build and push Build-Tools image + id: build_build_tools + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: ./containers/build-tools + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}/build-tools:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/build-tools:latest + no-cache: true + + - name: Sign Build-Tools image with cosign + run: | + cosign sign --yes \ + ghcr.io/${{ github.repository }}/build-tools@${{ steps.build_build_tools.outputs.digest }} + + - name: Generate SBOM for Build-Tools image + uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + with: + image: ghcr.io/${{ github.repository }}/build-tools@${{ steps.build_build_tools.outputs.digest }} + format: spdx-json + output-file: build-tools-sbom.spdx.json + + - name: Attest SBOM for Build-Tools image + continue-on-error: true + run: | + for attempt in 1 2 3; do + echo "Attempt $attempt of 3..." + if cosign attest --yes \ + --predicate build-tools-sbom.spdx.json \ + --type spdxjson \ + ghcr.io/${{ github.repository }}/build-tools@${{ steps.build_build_tools.outputs.digest }}; then + echo "SBOM attestation succeeded on attempt $attempt" + exit 0 + fi + if [ "$attempt" -lt 3 ]; then + sleep_time=$((30 * attempt)) + echo "Attempt $attempt failed, retrying in ${sleep_time}s..." + sleep "$sleep_time" + fi + done + echo "::warning::SBOM attestation for build-tools failed after 3 attempts (Rekor may be unavailable)" + exit 1 + release: name: Create Release runs-on: ubuntu-latest - needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act] + needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act, build-build-tools] steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 diff --git a/containers/build-tools/Dockerfile b/containers/build-tools/Dockerfile new file mode 100644 index 00000000..afd03f2c --- /dev/null +++ b/containers/build-tools/Dockerfile @@ -0,0 +1,96 @@ +# Build-tools sysroot image for ARC/DinD deployments. +# +# Provides system-level build infrastructure (compilers, linkers, dev libraries) +# that requires root to install. On ARC/DinD, the agent container cannot run as +# root, so these packages must be baked in at image build time. +# +# Language SDKs (Go, Node, Java, .NET, Rust, Python) are NOT included here — +# they are installed on-demand by setup-* actions into a shared tool cache volume. +# +# Usage in docker-compose (generated by AWF when runner.topology = arc-dind): +# +# sysroot-stage: +# image: ghcr.io/github/gh-aw-firewall/build-tools: +# volumes: +# - sysroot:/sysroot +# entrypoint: ["/bin/sh", "-c"] +# command: ["cp -a /usr /lib /bin /sbin /etc /sysroot/ && [ -d /lib64 ] && cp -a /lib64 /sysroot/ || true"] +# +# agent: +# depends_on: +# sysroot-stage: { condition: service_completed_successfully } +# volumes: +# - sysroot:/host:ro + +FROM ubuntu:22.04 + +LABEL org.opencontainers.image.source="https://github.com/github/gh-aw-firewall" +LABEL org.opencontainers.image.description="Build-tools sysroot for ARC/DinD agent containers" + +# Reuse Azure mirror optimization from the agent Dockerfile +RUN if getent hosts azure.archive.ubuntu.com >/dev/null 2>&1; then \ + echo "Using Azure apt mirror (DNS resolved successfully)"; \ + if [ -f /etc/apt/sources.list ]; then \ + sed -i 's|http://archive.ubuntu.com|http://azure.archive.ubuntu.com|g' /etc/apt/sources.list; \ + sed -i 's|http://security.ubuntu.com|http://azure.archive.ubuntu.com|g' /etc/apt/sources.list; \ + fi; \ + else \ + echo "Azure apt mirror not reachable, using default archive.ubuntu.com"; \ + fi + +# Install build essentials and system libraries. +# This mirrors the packages available on GitHub-hosted runner VMs +# (see actions/runner-images toolset-2204.json) minus language runtimes. +RUN set -eux; \ + force_archive_mirror() { \ + echo "Falling back to archive.ubuntu.com mirror..." >&2; \ + if [ -f /etc/apt/sources.list ]; then \ + sed -i 's|http://azure.archive.ubuntu.com|http://archive.ubuntu.com|g' /etc/apt/sources.list; \ + sed -i 's|http://security.ubuntu.com|http://archive.ubuntu.com|g' /etc/apt/sources.list 2>/dev/null || true; \ + fi; \ + rm -rf /var/lib/apt/lists/* && apt-get update; \ + }; \ + apt_update_retry() { \ + local i; for i in 1 2 3; do \ + rm -rf /var/lib/apt/lists/*; \ + if apt-get update > /tmp/apt-update.log 2>&1; then \ + cat /tmp/apt-update.log; \ + if ! grep -q "Failed to fetch" /tmp/apt-update.log; then return 0; fi; \ + else \ + cat /tmp/apt-update.log; \ + fi; \ + echo "apt-get update attempt $i/3 failed, retrying in $((i*10))s..." >&2; sleep $((i*10)); \ + done; \ + echo "All apt-get update retries failed, falling back to archive.ubuntu.com..." >&2; \ + force_archive_mirror; \ + }; \ + apt_install_retry() { \ + apt-get install -y --no-install-recommends "$@" && return 0; \ + echo "apt-get install failed, forcing archive.ubuntu.com and retrying..." >&2; \ + force_archive_mirror; \ + apt-get install -y --no-install-recommends "$@"; \ + }; \ + apt_update_retry && \ + apt_install_retry \ + # Build toolchain + build-essential gcc g++ make cmake autoconf automake libtool \ + pkg-config binutils bison flex m4 \ + # Development libraries + libssl-dev libc6-dev libicu-dev libsqlite3-dev zlib1g-dev \ + libcurl4-openssl-dev libffi-dev libreadline-dev libyaml-dev \ + libxml2-dev libxslt1-dev libbz2-dev liblzma-dev \ + libncurses5-dev libgdbm-dev libgdbm-compat-dev \ + # Runtime libraries commonly needed by builds + libgdiplus libev-dev \ + # System utilities + bash coreutils findutils grep sed gawk \ + tar gzip bzip2 xz-utils unzip zip \ + # Networking and certs + ca-certificates curl wget git jq \ + dnsutils net-tools netcat-openbsd \ + # Agent container dependencies (must match containers/agent/Dockerfile) + libcap2-bin gosu gnupg gh \ + && \ + # Upgrade all packages for security patches + apt-get upgrade -y && \ + rm -rf /var/lib/apt/lists/* /tmp/* diff --git a/docs/arc-dind.md b/docs/arc-dind.md index d6b63f76..e48cd0a8 100644 --- a/docs/arc-dind.md +++ b/docs/arc-dind.md @@ -2,7 +2,90 @@ AWF supports ARC runners where the runner filesystem and Docker daemon filesystem are split (DinD sidecar patterns). -## What AWF now handles automatically +## Runner topology selector + +The simplest way to configure AWF for ARC/DinD is through the `runner.topology` config key: + +```json +{ + "runner": { + "topology": "arc-dind" + } +} +``` + +When `runner.topology` is set to `"arc-dind"`, AWF applies overridable defaults: + +| Behavior | Default | Override | +|----------|---------|----------| +| Network isolation (no NET_ADMIN) | `true` | `network.isolation` | +| DinD pre-stage dirs | `true` | `dind.preStageDirs` | +| Sysroot image for `/host` base | `build-tools:` | `runner.sysrootImage` | +| Tool cache warning if under `/opt` | Emitted | Set `RUNNER_TOOL_CACHE` to shared path | + +An explicit value in any downstream key always overrides the topology default. + +## Build-tools sysroot image + +On ARC/DinD, the standard system mounts (`/usr:/host/usr:ro`, etc.) resolve to the runner container's filesystem, which is invisible to the Docker daemon's split filesystem. The `build-tools` sysroot image solves this by providing a pre-built Ubuntu 22.04 image containing system-level build infrastructure: + +- **Compilers & linkers**: gcc, g++, make, cmake, autoconf, binutils +- **Dev libraries**: libssl-dev, libc6-dev, libicu-dev, zlib1g-dev +- **System utilities**: bash, coreutils, git, curl, wget, jq +- **Agent dependencies**: libcap2-bin (capsh), gosu, gnupg, gh + +### How it works + +1. AWF emits a `sysroot-stage` init service in the compose file +2. The init container copies the build-tools image FS into a named `sysroot` volume +3. The agent mounts the `sysroot` volume read-only at `/host` +4. `entrypoint.sh` finds `/host/bin/sh` and `capsh`, chroots successfully + +```yaml +# Generated docker-compose.yml (simplified) +services: + sysroot-stage: + image: ghcr.io/github/gh-aw-firewall/build-tools:0.28.0 + volumes: ["sysroot:/sysroot"] + entrypoint: ["/bin/sh", "-c"] + command: ["cp -a /usr /lib /bin /sbin /etc /sysroot/ ..."] + + agent: + depends_on: + sysroot-stage: { condition: service_completed_successfully } + volumes: + - sysroot:/host:ro + - /tmp/gh-aw/tool-cache:/host/tmp/gh-aw/tool-cache:ro + +volumes: + sysroot: {} +``` + +### Custom sysroot image + +Override the default build-tools image: + +```json +{ + "runner": { + "topology": "arc-dind", + "sysrootImage": "ghcr.io/my-org/custom-sysroot:latest" + } +} +``` + +## Tool cache for language SDKs + +Language SDKs (Go, Node, Java, .NET) are NOT baked into the sysroot image. They are installed on-demand by `setup-*` actions into a shared tool cache volume. + +**Important**: On ARC, `RUNNER_TOOL_CACHE` must point to a shared path visible to both the runner container and the DinD daemon (e.g., `/tmp/gh-aw/tool-cache`). The default `/opt/hostedtoolcache` is invisible to the DinD daemon. + +```yaml +# Early in workflow, before setup-* actions: +- run: echo "RUNNER_TOOL_CACHE=/tmp/gh-aw/tool-cache" >> "$GITHUB_ENV" +``` + +## What AWF handles automatically - Split-filesystem probing for `--docker-host-path-prefix` - Chroot staging for: @@ -12,7 +95,9 @@ AWF supports ARC runners where the runner filesystem and Docker daemon filesyste - generated chroot `/etc/hosts` - DinD `DOCKER_HOST` propagation into agent/MCP environments when DinD is detected -## ARC/DinD stdin config surface +## Explicit ARC/DinD config surface + +For fine-grained control (or when not using `runner.topology`): ```json { diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index b4bba6de..c80c75d9 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -777,6 +777,25 @@ "description": "The GitHub deployment type. 'github.com' = GitHub.com (default), 'ghes' = GitHub Enterprise Server (on-premises), 'ghec' = GitHub Enterprise Cloud (*.ghe.com tenants), 'ghec-self-hosted' = GHEC with self-hosted runners." } } + }, + "runner": { + "type": "object", + "description": "Runner topology configuration. Declares the runner deployment model so AWF can activate the correct split-filesystem, sysroot, and network isolation behavior.", + "additionalProperties": false, + "properties": { + "topology": { + "type": "string", + "enum": [ + "standard", + "arc-dind" + ], + "description": "Runner deployment topology. 'standard' (default) = GitHub-hosted VM or self-hosted runner with local Docker. 'arc-dind' = ARC (Actions Runner Controller) with Docker-in-Docker sidecar, where the runner and Docker daemon have separate filesystems. When set to 'arc-dind', AWF applies overridable defaults: network.isolation=true, dind.preStageDirs=true, sysroot image activation, and tool cache validation." + }, + "sysrootImage": { + "type": "string", + "description": "Container image providing system-level build tools (gcc, make, libraries) for the agent's chroot base. Used as an init container that copies its filesystem into a named volume mounted at /host. Only used when runner.topology is 'arc-dind'. Defaults to 'ghcr.io/github/gh-aw-firewall/build-tools:'." + } + } } }, "$defs": { diff --git a/schemas/token-usage.schema.json b/schemas/token-usage.schema.json index 39ad7c99..8996e6b2 100644 --- a/schemas/token-usage.schema.json +++ b/schemas/token-usage.schema.json @@ -103,7 +103,10 @@ }, "x_initiator": { "type": "string", - "enum": ["agent", "user"], + "enum": [ + "agent", + "user" + ], "description": "The X-Initiator header value sent on the request. Determines billing treatment: 'agent' requests are not billed as premium, 'user' requests are. Present only for Copilot-bound requests." }, "billing": { @@ -114,11 +117,26 @@ "type": "object", "description": "X-Quota-Snapshot-Chat parsed fields.", "properties": { - "ent": { "type": "string", "description": "Entitlement count (-1 = unlimited)." }, - "ov": { "type": "string", "description": "Overage requests made this period." }, - "ovPerm": { "type": "string", "description": "Whether overage is allowed (true/false)." }, - "rem": { "type": "string", "description": "Percentage of entitlement remaining." }, - "rst": { "type": "string", "description": "Quota reset date (RFC 3339 UTC)." } + "ent": { + "type": "string", + "description": "Entitlement count (-1 = unlimited)." + }, + "ov": { + "type": "string", + "description": "Overage requests made this period." + }, + "ovPerm": { + "type": "string", + "description": "Whether overage is allowed (true/false)." + }, + "rem": { + "type": "string", + "description": "Percentage of entitlement remaining." + }, + "rst": { + "type": "string", + "description": "Quota reset date (RFC 3339 UTC)." + } }, "additionalProperties": true }, @@ -126,17 +144,41 @@ "type": "object", "description": "X-Quota-Snapshot-Premium-Chat parsed fields. Tracks premium request units (PRU).", "properties": { - "ent": { "type": "string", "description": "Premium entitlement count." }, - "ov": { "type": "string", "description": "Premium overage requests made." }, - "ovPerm": { "type": "string", "description": "Whether premium overage is allowed." }, - "rem": { "type": "string", "description": "Percentage of premium entitlement remaining." }, - "rst": { "type": "string", "description": "Premium quota reset date." } + "ent": { + "type": "string", + "description": "Premium entitlement count." + }, + "ov": { + "type": "string", + "description": "Premium overage requests made." + }, + "ovPerm": { + "type": "string", + "description": "Whether premium overage is allowed." + }, + "rem": { + "type": "string", + "description": "Percentage of premium entitlement remaining." + }, + "rst": { + "type": "string", + "description": "Premium quota reset date." + } }, "additionalProperties": true }, - "rate_limit": { "type": "string", "description": "X-RateLimit-Limit value." }, - "rate_remaining": { "type": "string", "description": "X-RateLimit-Remaining value." }, - "rate_reset": { "type": "string", "description": "X-RateLimit-Reset value (Unix timestamp)." } + "rate_limit": { + "type": "string", + "description": "X-RateLimit-Limit value." + }, + "rate_remaining": { + "type": "string", + "description": "X-RateLimit-Remaining value." + }, + "rate_reset": { + "type": "string", + "description": "X-RateLimit-Reset value (Unix timestamp)." + } }, "additionalProperties": true }, diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index b4bba6de..c80c75d9 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -777,6 +777,25 @@ "description": "The GitHub deployment type. 'github.com' = GitHub.com (default), 'ghes' = GitHub Enterprise Server (on-premises), 'ghec' = GitHub Enterprise Cloud (*.ghe.com tenants), 'ghec-self-hosted' = GHEC with self-hosted runners." } } + }, + "runner": { + "type": "object", + "description": "Runner topology configuration. Declares the runner deployment model so AWF can activate the correct split-filesystem, sysroot, and network isolation behavior.", + "additionalProperties": false, + "properties": { + "topology": { + "type": "string", + "enum": [ + "standard", + "arc-dind" + ], + "description": "Runner deployment topology. 'standard' (default) = GitHub-hosted VM or self-hosted runner with local Docker. 'arc-dind' = ARC (Actions Runner Controller) with Docker-in-Docker sidecar, where the runner and Docker daemon have separate filesystems. When set to 'arc-dind', AWF applies overridable defaults: network.isolation=true, dind.preStageDirs=true, sysroot image activation, and tool cache validation." + }, + "sysrootImage": { + "type": "string", + "description": "Container image providing system-level build tools (gcc, make, libraries) for the agent's chroot base. Used as an init container that copies its filesystem into a named volume mounted at /host. Only used when runner.topology is 'arc-dind'. Defaults to 'ghcr.io/github/gh-aw-firewall/build-tools:'." + } + } } }, "$defs": { diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index db185277..d1e26cb3 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -312,4 +312,81 @@ describe('generateDockerCompose', () => { expect(result.services.agent.environment?.AWF_NETWORK_ISOLATION).toBeUndefined(); }); }); + + describe('sysroot-stage service (runner.topology = arc-dind)', () => { + it('adds sysroot-stage service when runnerTopology is arc-dind', () => { + const config = { ...mockConfig, runnerTopology: 'arc-dind' as const }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.services['sysroot-stage']).toBeDefined(); + expect(result.services['sysroot-stage'].container_name).toBe('awf-sysroot-stage'); + expect(result.services['sysroot-stage'].image).toBe( + 'ghcr.io/github/gh-aw-firewall/build-tools:latest', + ); + }); + + it('does not add sysroot-stage when runnerTopology is not set', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + expect(result.services['sysroot-stage']).toBeUndefined(); + }); + + it('does not add sysroot-stage when runnerTopology is standard', () => { + const config = { ...mockConfig, runnerTopology: 'standard' as const }; + const result = generateDockerCompose(config, mockNetworkConfig); + expect(result.services['sysroot-stage']).toBeUndefined(); + }); + + it('agent depends_on sysroot-stage with service_completed_successfully', () => { + const config = { ...mockConfig, runnerTopology: 'arc-dind' as const }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.services.agent.depends_on).toMatchObject({ + 'sysroot-stage': { condition: 'service_completed_successfully' }, + }); + }); + + it('declares sysroot named volume', () => { + const config = { ...mockConfig, runnerTopology: 'arc-dind' as const }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.volumes).toBeDefined(); + expect(result.volumes!.sysroot).toEqual({}); + }); + + it('adds sysroot:/host:ro to agent volumes', () => { + const config = { ...mockConfig, runnerTopology: 'arc-dind' as const }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.services.agent.volumes).toContain('sysroot:/host:ro'); + }); + + it('does not declare sysroot volume when topology is standard', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + expect(result.volumes).toBeUndefined(); + }); + + it('uses custom sysrootImage when configured', () => { + const config = { + ...mockConfig, + runnerTopology: 'arc-dind' as const, + sysrootImage: 'ghcr.io/my-org/custom:v1', + }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.services['sysroot-stage'].image).toBe('ghcr.io/my-org/custom:v1'); + }); + + it('uses imageTag in default sysroot image', () => { + const config = { + ...mockConfig, + runnerTopology: 'arc-dind' as const, + imageTag: '0.28.0', + }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.services['sysroot-stage'].image).toBe( + 'ghcr.io/github/gh-aw-firewall/build-tools:0.28.0', + ); + }); + }); }); diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 723f233b..1c1ec1d0 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -6,11 +6,13 @@ import { parseImageTag } from './image-tag'; import { SslConfig } from './host-env'; import { getRealUserHome } from './host-identity'; import { resolveLogPaths } from './log-paths'; +import { logger } from './logger'; import { buildSquidService } from './services/squid-service'; import { buildAgentEnvironment, buildAgentVolumes, buildAgentService, buildIptablesInitService } from './services/agent-service'; import { buildApiProxyService } from './services/api-proxy-service'; import { buildDohProxyService } from './services/doh-proxy-service'; import { buildCliProxyService } from './services/cli-proxy-service'; +import { buildSysrootStageService, isSysrootEnabled } from './services/sysroot-service'; import { TOPOLOGY_NETWORK_NAME } from './topology'; /** @@ -144,6 +146,35 @@ export function generateDockerCompose( 'agent': agentService, }; + // ── Optional: sysroot-stage init container (ARC/DinD) ───────────────────── + + const sysrootActive = isSysrootEnabled(config); + if (sysrootActive) { + const sysrootService = buildSysrootStageService({ + config, + registry, + imageTag: parsedImageTag.tag, + }); + services['sysroot-stage'] = sysrootService; + + // Agent waits for sysroot copy to complete before starting + agentService.depends_on['sysroot-stage'] = { + condition: 'service_completed_successfully', + }; + + // Warn if tool cache is under /opt (invisible to the DinD daemon) + const toolCachePath = config.runnerToolCachePath || process.env.RUNNER_TOOL_CACHE; + if (!toolCachePath || toolCachePath.startsWith('/opt')) { + logger.warn( + 'ARC/DinD: RUNNER_TOOL_CACHE is ' + + (toolCachePath ? `under /opt (${toolCachePath})` : 'not set') + + ', which is invisible to the DinD daemon. ' + + 'Redirect it to a shared volume path (e.g. /tmp/gh-aw/tool-cache) ' + + 'so setup-* action outputs are available inside the agent container.', + ); + } + } + if (!networkIsolation) { const iptablesInitService = buildIptablesInitService({ agentService, @@ -212,6 +243,18 @@ export function generateDockerCompose( // ── Final compose result ─────────────────────────────────────────────────── + // When sysroot staging is active, declare the named volume and mount it + // on the agent at /host (replacing the per-directory system bind mounts). + const namedVolumes: Record | undefined = sysrootActive + ? { sysroot: {} } + : undefined; + + if (sysrootActive) { + // The sysroot named volume provides /host content (system binaries, libs, etc.) + // via the sysroot-stage init container instead of per-directory bind mounts. + agentVolumes.push('sysroot:/host:ro'); + } + if (networkIsolation) { // Topology enforcement: the agent (and sidecars) live on an `internal` // network with no route to the internet. Squid is dual-homed — attached to @@ -242,6 +285,7 @@ export function generateDockerCompose( driver: 'bridge', }, }, + ...(namedVolumes && { volumes: namedVolumes }), }; return composeResult; @@ -254,6 +298,7 @@ export function generateDockerCompose( external: true, }, }, + ...(namedVolumes && { volumes: namedVolumes }), }; return composeResult; diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 79aa046c..56c19f42 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -496,4 +496,21 @@ describe('mapAwfFileConfigToCliOptions', () => { const result = mapAwfFileConfigToCliOptions({}); expect(result.platformType).toBeUndefined(); }); + + it('maps runner.topology to runnerTopology', () => { + const result = mapAwfFileConfigToCliOptions({ runner: { topology: 'arc-dind' } }); + expect(result.runnerTopology).toBe('arc-dind'); + }); + + it('maps runner.sysrootImage to sysrootImage', () => { + const result = mapAwfFileConfigToCliOptions({ + runner: { topology: 'arc-dind', sysrootImage: 'ghcr.io/my-org/sysroot:v1' }, + }); + expect(result.sysrootImage).toBe('ghcr.io/my-org/sysroot:v1'); + }); + + it('leaves runnerTopology undefined when runner is not set', () => { + const result = mapAwfFileConfigToCliOptions({}); + expect(result.runnerTopology).toBeUndefined(); + }); }); diff --git a/src/config-file-validation.test.ts b/src/config-file-validation.test.ts index e7c451fa..55f0e562 100644 --- a/src/config-file-validation.test.ts +++ b/src/config-file-validation.test.ts @@ -571,4 +571,40 @@ describe('validateAwfFileConfig', () => { it('accepts empty config object', () => { expect(validateAwfFileConfig({})).toEqual([]); }); + + it('accepts valid runner topology config', () => { + const errors = validateAwfFileConfig({ + runner: { topology: 'arc-dind' }, + }); + expect(errors).toEqual([]); + }); + + it('accepts runner with sysrootImage', () => { + const errors = validateAwfFileConfig({ + runner: { topology: 'arc-dind', sysrootImage: 'ghcr.io/my-org/sysroot:v1' }, + }); + expect(errors).toEqual([]); + }); + + it('accepts standard topology', () => { + const errors = validateAwfFileConfig({ + runner: { topology: 'standard' }, + }); + expect(errors).toEqual([]); + }); + + it('rejects invalid runner topology value', () => { + const errors = validateAwfFileConfig({ + runner: { topology: 'invalid' }, + }); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some(e => e.includes('topology'))).toBe(true); + }); + + it('rejects unknown runner properties', () => { + const errors = validateAwfFileConfig({ + runner: { topology: 'arc-dind', unknownProp: true }, + }); + expect(errors.length).toBeGreaterThan(0); + }); }); diff --git a/src/config-file.ts b/src/config-file.ts index 2fd5f9f6..7e842b0b 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -152,6 +152,10 @@ export interface AwfFileConfig { platform?: { type?: 'github.com' | 'ghes' | 'ghec' | 'ghec-self-hosted'; }; + runner?: { + topology?: 'standard' | 'arc-dind'; + sysrootImage?: string; + }; } /** diff --git a/src/config-mapper.ts b/src/config-mapper.ts index a6fc3428..677741f6 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -132,5 +132,8 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record = {}): WrapperConfig { + return { + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + workDir: '/tmp/awf-test', + ...overrides, + } as WrapperConfig; +} + +describe('isSysrootEnabled', () => { + it('returns false when runnerTopology is not set', () => { + expect(isSysrootEnabled(makeConfig())).toBe(false); + }); + + it('returns false when runnerTopology is standard', () => { + expect(isSysrootEnabled(makeConfig({ runnerTopology: 'standard' }))).toBe(false); + }); + + it('returns true when runnerTopology is arc-dind', () => { + expect(isSysrootEnabled(makeConfig({ runnerTopology: 'arc-dind' }))).toBe(true); + }); +}); + +describe('resolveSysrootImage', () => { + it('returns undefined when sysroot is not enabled', () => { + expect(resolveSysrootImage(makeConfig())).toBeUndefined(); + }); + + it('returns default build-tools image when no override', () => { + const config = makeConfig({ runnerTopology: 'arc-dind' }); + expect(resolveSysrootImage(config)).toBe( + 'ghcr.io/github/gh-aw-firewall/build-tools:latest', + ); + }); + + it('uses custom imageTag in default image', () => { + const config = makeConfig({ runnerTopology: 'arc-dind', imageTag: 'v0.28.0' }); + expect(resolveSysrootImage(config)).toBe( + 'ghcr.io/github/gh-aw-firewall/build-tools:v0.28.0', + ); + }); + + it('uses custom imageRegistry in default image', () => { + const config = makeConfig({ + runnerTopology: 'arc-dind', + imageRegistry: 'my-registry.example.com/awf', + }); + expect(resolveSysrootImage(config)).toBe( + 'my-registry.example.com/awf/build-tools:latest', + ); + }); + + it('returns explicit sysrootImage when set', () => { + const config = makeConfig({ + runnerTopology: 'arc-dind', + sysrootImage: 'ghcr.io/my-org/custom-sysroot:v1', + }); + expect(resolveSysrootImage(config)).toBe('ghcr.io/my-org/custom-sysroot:v1'); + }); +}); + +describe('buildSysrootStageService', () => { + it('generates a service with correct container name', () => { + const service = buildSysrootStageService({ + config: makeConfig({ runnerTopology: 'arc-dind' }), + registry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + expect(service.container_name).toBe('awf-sysroot-stage'); + }); + + it('uses default build-tools image', () => { + const service = buildSysrootStageService({ + config: makeConfig({ runnerTopology: 'arc-dind' }), + registry: 'ghcr.io/github/gh-aw-firewall', + imageTag: '0.28.0', + }); + expect(service.image).toBe('ghcr.io/github/gh-aw-firewall/build-tools:0.28.0'); + }); + + it('uses explicit sysrootImage when configured', () => { + const service = buildSysrootStageService({ + config: makeConfig({ + runnerTopology: 'arc-dind', + sysrootImage: 'ghcr.io/my-org/sysroot:v2', + }), + registry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + expect(service.image).toBe('ghcr.io/my-org/sysroot:v2'); + }); + + it('mounts sysroot named volume', () => { + const service = buildSysrootStageService({ + config: makeConfig({ runnerTopology: 'arc-dind' }), + registry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + expect(service.volumes).toEqual(['sysroot:/sysroot']); + }); + + it('uses sh entrypoint and cp -a command', () => { + const service = buildSysrootStageService({ + config: makeConfig({ runnerTopology: 'arc-dind' }), + registry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + expect(service.entrypoint).toEqual(['/bin/sh', '-c']); + expect(service.command).toHaveLength(1); + expect(service.command[0]).toContain('cp -a'); + expect(service.command[0]).toContain('/sysroot/'); + }); + + it('includes sentinel file check for idempotent re-runs', () => { + const service = buildSysrootStageService({ + config: makeConfig({ runnerTopology: 'arc-dind' }), + registry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + expect(service.command[0]).toContain('.awf-sysroot-ready'); + }); + + it('has empty networks (no network needed for copy)', () => { + const service = buildSysrootStageService({ + config: makeConfig({ runnerTopology: 'arc-dind' }), + registry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + expect(service.networks).toEqual({}); + }); +}); diff --git a/src/services/sysroot-service.ts b/src/services/sysroot-service.ts new file mode 100644 index 00000000..55998655 --- /dev/null +++ b/src/services/sysroot-service.ts @@ -0,0 +1,73 @@ +import { WrapperConfig } from '../types'; +import { logger } from '../logger'; + +/** + * Default sysroot image when runner.topology is 'arc-dind' and no explicit + * sysrootImage is configured. + */ +function defaultSysrootImage(registry: string, tag: string): string { + return `${registry}/build-tools:${tag}`; +} + +interface SysrootServiceParams { + config: WrapperConfig; + registry: string; + imageTag: string; +} + +/** + * Builds the sysroot-stage init service for ARC/DinD deployments. + * + * This service runs once before the agent starts: it copies the build-tools + * image's filesystem (compilers, linkers, dev libraries) into a named volume + * that the agent then mounts read-only at /host. + * + * The copy uses `cp -a` to preserve permissions, symlinks, and timestamps. + * /lib64 is conditionally copied (exists on amd64, not on arm64). + */ +export function buildSysrootStageService(params: SysrootServiceParams): any { + const { config, registry, imageTag } = params; + const image = config.sysrootImage || defaultSysrootImage(registry, imageTag); + + logger.info(`ARC/DinD: sysroot-stage will use image ${image}`); + + return { + container_name: 'awf-sysroot-stage', + image, + volumes: ['sysroot:/sysroot'], + entrypoint: ['/bin/sh', '-c'], + command: [ + 'set -eu; ' + + 'if [ -f /sysroot/.awf-sysroot-ready ]; then ' + + ' echo "Sysroot volume already populated, skipping copy"; ' + + ' exit 0; ' + + 'fi; ' + + 'echo "Copying sysroot filesystem..."; ' + + 'for d in usr lib bin sbin etc; do ' + + ' [ -d "/$d" ] && cp -a "/$d" /sysroot/; ' + + 'done; ' + + '[ -d /lib64 ] && cp -a /lib64 /sysroot/ || true; ' + + 'touch /sysroot/.awf-sysroot-ready; ' + + 'echo "Sysroot copy complete"', + ], + networks: {}, + }; +} + +/** + * Returns true when the config indicates ARC/DinD topology with sysroot + * staging enabled. + */ +export function isSysrootEnabled(config: WrapperConfig): boolean { + return config.runnerTopology === 'arc-dind'; +} + +/** + * Resolves the effective sysroot image reference for diagnostics/logging. + */ +export function resolveSysrootImage(config: WrapperConfig): string | undefined { + if (!isSysrootEnabled(config)) return undefined; + const registry = config.imageRegistry || 'ghcr.io/github/gh-aw-firewall'; + const tag = config.imageTag || 'latest'; + return config.sysrootImage || defaultSysrootImage(registry, tag); +} diff --git a/src/types/docker.ts b/src/types/docker.ts index 8badba56..bc6c28fc 100644 --- a/src/types/docker.ts +++ b/src/types/docker.ts @@ -334,6 +334,15 @@ interface DockerService { */ command?: string[]; + /** + * Entrypoint for the container + * + * Overrides the ENTRYPOINT from the Dockerfile. + * + * @example ['/bin/sh', '-c'] + */ + entrypoint?: string[]; + /** * Port mappings from host to container * diff --git a/src/types/platform-options.ts b/src/types/platform-options.ts index b0344af8..fb6ba6f1 100644 --- a/src/types/platform-options.ts +++ b/src/types/platform-options.ts @@ -1,7 +1,9 @@ /** - * GitHub platform deployment type options. + * GitHub platform deployment type and runner topology options. */ +export type RunnerTopology = 'standard' | 'arc-dind'; + export interface PlatformOptions { /** * The GitHub deployment type. Explicitly declares the environment so AWF can @@ -17,4 +19,30 @@ export interface PlatformOptions { * regardless of the resolved API target hostname. */ platformType?: 'github.com' | 'ghes' | 'ghec' | 'ghec-self-hosted'; + + /** + * Runner deployment topology. + * + * - 'standard' (default) — GitHub-hosted VM or self-hosted runner with local Docker. + * - 'arc-dind' — ARC (Actions Runner Controller) with Docker-in-Docker sidecar, + * where the runner and Docker daemon have separate filesystems. + * + * When set to 'arc-dind', AWF applies overridable defaults: + * - network.isolation = true (ARC k8s lacks NET_ADMIN) + * - dind.preStageDirs = true + * - Sysroot image activation (build-tools init container) + * - Tool cache validation (warns if under /opt) + */ + runnerTopology?: RunnerTopology; + + /** + * Container image providing system-level build tools (gcc, make, libraries) + * for the agent's chroot base on ARC/DinD. + * + * Used as an init container that copies its filesystem into a named volume + * mounted at /host. Only used when runnerTopology is 'arc-dind'. + * + * Defaults to 'ghcr.io/github/gh-aw-firewall/build-tools:'. + */ + sysrootImage?: string; } From 901765164c118d808307e7252613a6fdd359f5f6 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 29 Jun 2026 16:26:05 -0700 Subject: [PATCH 2/3] test: cover arc-dind sysroot compose branches Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compose-generator.test.ts | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index d1e26cb3..6b3828f6 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -1,5 +1,6 @@ import { generateDockerCompose } from './compose-generator'; import { ACT_PRESET_BASE_IMAGE } from './host-identity'; +import { logger } from './logger'; import { WrapperConfig } from './types'; import { baseConfig, mockNetworkConfig } from './test-helpers/docker-test-fixtures.test-utils'; import * as fs from 'fs'; @@ -388,5 +389,46 @@ describe('generateDockerCompose', () => { 'ghcr.io/github/gh-aw-firewall/build-tools:0.28.0', ); }); + + it('warns when runnerToolCachePath is under /opt', () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(); + const config = { + ...mockConfig, + runnerTopology: 'arc-dind' as const, + runnerToolCachePath: '/opt/hostedtoolcache', + }; + + generateDockerCompose(config, mockNetworkConfig); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('under /opt (/opt/hostedtoolcache)') + ); + warnSpy.mockRestore(); + }); + + it('does not warn when runnerToolCachePath is on a shared path', () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(); + const config = { + ...mockConfig, + runnerTopology: 'arc-dind' as const, + runnerToolCachePath: '/var/lib/awf/tool-cache', + }; + + generateDockerCompose(config, mockNetworkConfig); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('declares sysroot volume in network-isolation mode', () => { + const config = { + ...mockConfig, + networkIsolation: true, + runnerTopology: 'arc-dind' as const, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.volumes).toEqual({ sysroot: {} }); + }); }); }); From b9b2bb3efd76da9fb0b08f70762139a6a55206e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:43:42 +0000 Subject: [PATCH 3/3] fix: wire arc-dind sysroot runtime behavior --- docs/arc-dind.md | 6 ++---- src/commands/build-config.test.ts | 14 ++++++++++++++ src/commands/build-config.ts | 2 ++ src/compose-generator.test.ts | 26 ++++++++++++++++++++++++++ src/compose-generator.ts | 21 ++++++++++++++++++++- src/services/sysroot-service.test.ts | 14 ++++++++++++-- src/services/sysroot-service.ts | 4 ++-- 7 files changed, 78 insertions(+), 9 deletions(-) diff --git a/docs/arc-dind.md b/docs/arc-dind.md index e48cd0a8..a40ae1a3 100644 --- a/docs/arc-dind.md +++ b/docs/arc-dind.md @@ -14,16 +14,14 @@ The simplest way to configure AWF for ARC/DinD is through the `runner.topology` } ``` -When `runner.topology` is set to `"arc-dind"`, AWF applies overridable defaults: +When `runner.topology` is set to `"arc-dind"`, AWF enables ARC/DinD-specific sysroot staging behavior: | Behavior | Default | Override | |----------|---------|----------| -| Network isolation (no NET_ADMIN) | `true` | `network.isolation` | -| DinD pre-stage dirs | `true` | `dind.preStageDirs` | | Sysroot image for `/host` base | `build-tools:` | `runner.sysrootImage` | | Tool cache warning if under `/opt` | Emitted | Set `RUNNER_TOOL_CACHE` to shared path | -An explicit value in any downstream key always overrides the topology default. +Other ARC/DinD settings (for example `network.isolation` and `dind.preStageDirs`) are configured explicitly through their own fields. ## Build-tools sysroot image diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index 9d8ccbd5..2e75d453 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -304,6 +304,20 @@ describe('buildConfig', () => { expect(config.runnerToolCachePath).toBe('/opt/hostedtoolcache'); }); + it('should pass through runnerTopology', () => { + const config = buildConfig(makeInputs({ + options: { ...makeInputs().options, runnerTopology: 'arc-dind' }, + })); + expect(config.runnerTopology).toBe('arc-dind'); + }); + + it('should pass through sysrootImage', () => { + const config = buildConfig(makeInputs({ + options: { ...makeInputs().options, sysrootImage: 'ghcr.io/my-org/sysroot:v1' }, + })); + expect(config.sysrootImage).toBe('ghcr.io/my-org/sysroot:v1'); + }); + it('should pass through chroot identity fields', () => { const config = buildConfig(makeInputs({ options: { diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index abc84256..c85a245b 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -224,6 +224,8 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { awfDockerHost: options.dockerHost as string | undefined, upstreamProxy, dockerHostPathPrefix, + runnerTopology: options.runnerTopology as 'standard' | 'arc-dind' | undefined, + sysrootImage: options.sysrootImage as string | undefined, chrootBinariesSourcePath: options.chrootBinariesSourcePath as string | undefined, chrootIdentity, dind, diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index 6b3828f6..3cf5ef40 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -361,6 +361,32 @@ describe('generateDockerCompose', () => { expect(result.services.agent.volumes).toContain('sysroot:/host:ro'); }); + it('does not retain base-system bind mounts that shadow sysroot', () => { + const config = { + ...mockConfig, + runnerTopology: 'arc-dind' as const, + dockerHostPathPrefix: '/daemon-root', + }; + const result = generateDockerCompose(config, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).not.toContain('/usr:/host/usr:ro'); + expect(volumes).not.toContain('/bin:/host/bin:ro'); + expect(volumes).not.toContain('/lib:/host/lib:ro'); + expect(volumes).not.toContain('/lib64:/host/lib64:ro'); + expect(volumes).not.toContain('/opt:/host/opt:ro'); + expect(volumes).not.toContain('/sys:/host/sys:ro'); + expect(volumes).not.toContain('/dev:/host/dev:ro'); + expect(volumes.some(v => v.includes(':/host/usr:ro'))).toBe(false); + expect(volumes.some(v => v.includes(':/host/bin:ro'))).toBe(false); + expect(volumes.some(v => v.includes(':/host/sbin:ro'))).toBe(false); + expect(volumes.some(v => v.includes(':/host/lib:ro'))).toBe(false); + expect(volumes.some(v => v.includes(':/host/lib64:ro'))).toBe(false); + expect(volumes.some(v => v.includes(':/host/opt:ro'))).toBe(false); + expect(volumes.some(v => v.includes(':/host/sys:ro'))).toBe(false); + expect(volumes.some(v => v.includes(':/host/dev:ro'))).toBe(false); + }); + it('does not declare sysroot volume when topology is standard', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfig); expect(result.volumes).toBeUndefined(); diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 1c1ec1d0..870ef2dc 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -103,6 +103,7 @@ export function generateDockerCompose( const effectiveHome = getRealUserHome(); const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd(); + const sysrootActive = isSysrootEnabled(config); const agentVolumes = buildAgentVolumes({ config, @@ -115,6 +116,25 @@ export function generateDockerCompose( initSignalDir, }); + if (sysrootActive) { + const sysrootShadowedTargets = new Set([ + '/host/usr', + '/host/bin', + '/host/sbin', + '/host/lib', + '/host/lib64', + '/host/opt', + '/host/sys', + '/host/dev', + ]); + const filteredVolumes = agentVolumes.filter(volume => { + const target = volume.split(':')[1]; + return !sysrootShadowedTargets.has(target); + }); + agentVolumes.length = 0; + agentVolumes.push(...filteredVolumes); + } + // ── Agent service ────────────────────────────────────────────────────────── const agentService = buildAgentService({ @@ -148,7 +168,6 @@ export function generateDockerCompose( // ── Optional: sysroot-stage init container (ARC/DinD) ───────────────────── - const sysrootActive = isSysrootEnabled(config); if (sysrootActive) { const sysrootService = buildSysrootStageService({ config, diff --git a/src/services/sysroot-service.test.ts b/src/services/sysroot-service.test.ts index 52523094..908c1565 100644 --- a/src/services/sysroot-service.test.ts +++ b/src/services/sysroot-service.test.ts @@ -125,12 +125,22 @@ describe('buildSysrootStageService', () => { expect(service.command[0]).toContain('.awf-sysroot-ready'); }); - it('has empty networks (no network needed for copy)', () => { + it('uses network_mode none (no network needed for copy)', () => { const service = buildSysrootStageService({ config: makeConfig({ runnerTopology: 'arc-dind' }), registry: 'ghcr.io/github/gh-aw-firewall', imageTag: 'latest', }); - expect(service.networks).toEqual({}); + expect(service.network_mode).toBe('none'); + }); + + it('copies /lib64 conditionally without masking copy failures', () => { + const service = buildSysrootStageService({ + config: makeConfig({ runnerTopology: 'arc-dind' }), + registry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + expect(service.command[0]).toContain('if [ -d /lib64 ]; then cp -a /lib64 /sysroot/; fi;'); + expect(service.command[0]).not.toContain('|| true'); }); }); diff --git a/src/services/sysroot-service.ts b/src/services/sysroot-service.ts index 55998655..528ac8cc 100644 --- a/src/services/sysroot-service.ts +++ b/src/services/sysroot-service.ts @@ -46,11 +46,11 @@ export function buildSysrootStageService(params: SysrootServiceParams): any { 'for d in usr lib bin sbin etc; do ' + ' [ -d "/$d" ] && cp -a "/$d" /sysroot/; ' + 'done; ' + - '[ -d /lib64 ] && cp -a /lib64 /sysroot/ || true; ' + + 'if [ -d /lib64 ]; then cp -a /lib64 /sysroot/; fi; ' + 'touch /sysroot/.awf-sysroot-ready; ' + 'echo "Sysroot copy complete"', ], - networks: {}, + network_mode: 'none', }; }